Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0001-system-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,4 @@ Both live **only** in the `LedgerPort` adapter in `puls3_server`. Nothing else i
1. **Layered architecture with business rules in Serverpod endpoints.** Faster to start, but rules would depend on Serverpod types, could not be shared with the app, and could not be tested without a database. Rejected: the hire lifecycle and price rules are the core of the product and must be testable on their own.
2. **Server signs everything (custodial for users too).** Simplest UX: no wallet in the app. Rejected: the server would hold users' funds and could register agents on behalf of any builder, which defeats the point of an on-chain identity and makes a server breach much worse.
3. **Business logic in Soroban contracts (hire lifecycle and escrow on-chain).** Most trustless, but every state change costs a transaction and a fee, contract changes need redeploys, and it doubles the contracts work before the Serverpod deadline. Rejected for the MVP; escrow is listed as out of scope in the vision and can move on-chain later.
4. **A TypeScript sidecar for all chain access** (using the reference Stellar SDKs). Mature libraries, but adds a second runtime and deployment to the backend, and takes chain work out of Serverpod. Kept only as the fallback in ADR-0003 if `stellar_dart` does not work.
4. **A TypeScript sidecar for all chain access** (using the reference Stellar SDKs). Mature libraries, but adds a second runtime and deployment to the backend, and takes chain work out of Serverpod. Kept only as the fallback in ADR-0003 if `stellar_dart` does not work. *(Amended 2026-10-07: the #96 spike passed with `stellar_dart` 2.3.0, and the ADR-0003 fallback is now pure Dart, `package:crypto` + ed25519 with hand-written XDR, so no sidecar is planned.)*
4 changes: 2 additions & 2 deletions docs/adr/0003-payment-rail-and-custody.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Consumers pay agents per task in USDC, and every agent has its own wallet ([visi
4. **Agent wallets: server-custodied G-accounts, testnet only.** The server generates a keypair per agent, stores the secret encrypted, funds the account, and adds the USDC trustline. Earnings are swept to the builder. **Mainnet requires moving agent wallets to smart accounts with spending limits first.**
5. **Server-side chain access:** reads through Stellar RPC over plain HTTP; signing and submitting with the pure-Dart `stellar_dart`, only inside the `LedgerPort` adapter (ADR-0001). Fallback if `stellar_dart` fails: a small TypeScript sidecar.

> **Unchanged by [ADR-0005](0005-align-agent-commerce-with-erc-8183-and-erc-8004.md).**
> **Unchanged by [ADR-0005](0005-align-agent-commerce-with-erc-8183-and-erc-8004.md).** **Amended 2026-10-07 (#96 spike):** `stellar_dart` 2.3.0 passed envelope decode and re-encode, network hash, and ed25519 verification (tests in `puls3_server/test/spike`). The fallback is no longer a TypeScript sidecar but pure Dart: `package:crypto` + ed25519 with hand-written XDR, behind the same `LedgerPort` adapter.

6. **Who signs:**

Expand Down Expand Up @@ -67,7 +67,7 @@ Consumers pay agents per task in USDC, and every agent has its own wallet ([visi
- Payments settle in one transaction and are verifiable from one HTTP call. No contract of ours holds user funds.
- The server is a custodian of agent earnings on testnet. A breach could drain agent balances, so balances stay small and are swept.
- Replay protection depends on the server's database (used hashes, paid hires), not on a contract.
- `stellar_dart` has low adoption. If it fails in #18, the sidecar fallback moves signing out of Serverpod.
- `stellar_dart` has low adoption. If it fails in #18, the sidecar fallback moves signing out of Serverpod. *(Amended 2026-10-07: the #96 spike passed, and the fallback is now pure Dart, so signing stays in Serverpod; see decision 5.)*

## Alternatives considered

Expand Down
14 changes: 7 additions & 7 deletions docs/architecture/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ This is the boundary between the Flutter app and Serverpod for the MVP. The app
| `HireEndpoint` | `prepareFund` | `hireId: int` | `PreparedTransaction` | Yes | `InvalidHireId`, `HireNotFound`, `HireNotOwned`, `InvalidHireTransition`, `SubmissionInProgress`, `PaymentAlreadySubmitted`, `ChainUnavailable` |
| `HireEndpoint` | `prepareComplete` | `hireId: int` | `PreparedTransaction` | Yes | `InvalidHireId`, `HireNotFound`, `HireNotOwned`, `InvalidHireTransition`, `SubmissionInProgress`, `ChainUnavailable` |
| `HireEndpoint` | `prepareReject` | `hireId: int`, `reason: String` | `PreparedTransaction` | Yes | `InvalidHireId`, `HireNotFound`, `HireNotOwned`, `InvalidHireTransition`, `InvalidRejectReason`, `SubmissionInProgress`, `ChainUnavailable` |
| `HireEndpoint` | `submitEscrowCall` | `hireId: int`, `preparationId: String`, `signedTransactionXdr: String` | `HireDetail` | Yes | `InvalidHireId`, `HireNotFound`, `HireNotOwned`, `InvalidHireTransition`, `PreparationNotFound`, `PreparationExpired`, `InvalidSignedEnvelope`, `EnvelopeMismatch`, `InvalidTransactionSignature` |
| `HireEndpoint` | `submitEscrowCall` | `hireId: int`, `preparationId: String`, `signedTransactionXdr: String` | `HireDetail` | Yes | `InvalidHireId`, `HireNotFound`, `HireNotOwned`, `InvalidHireTransition`, `PreparationNotFound`, `PreparationExpired`, `InvalidSignedEnvelope`, `EnvelopeMismatch`, `InvalidTransactionSignature`, `ChainDataUnavailable` |
| `HireEndpoint` | `getHire` | `hireId: int`, `consumer: String` | `HireDetail` | Yes | `InvalidHireId`, `InvalidStellarAddress`, `WalletMismatch`, `HireNotFound`, `HireNotOwned` |
| `HireEndpoint` | `listHires` | `consumer: String` | `List<HireSummary>` | Yes | `InvalidStellarAddress`, `WalletMismatch`, `PersistenceUnavailable` |
| `FeedbackEndpoint` | `getFeedbackEligibility` | `hireId: int`, `client: String` | `FeedbackEligibility` | Yes | `InvalidHireId`, `InvalidStellarAddress`, `WalletMismatch`, `HireNotFound`, `HireNotOwned`, `HireNotCompleted`, `HireAlreadyRated` |
Expand All @@ -63,7 +63,7 @@ This is the boundary between the Flutter app and Serverpod for the MVP. The app

**Lifecycle error rule.** Hire methods report a wrong hire state with one code per family, so clients can branch on it:

- Escrow methods raise `InvalidHireTransition` with `details.status` set to the current status when the hire is not in the state the call needs: `prepareCreateJob` and `prepareFund` need `open`; `prepareComplete` needs `submitted`; `prepareReject` needs `open` (a cancel before paying), `funded`, or `submitted` before `approvalDeadline`.
- Escrow methods raise `InvalidHireTransition` with `details.status` set to the current status when the hire is not in the state the call needs: `prepareCreateJob` needs a hire with no status yet (`details.status` is `null`: only a pending preparation exists, see [hire escrow states](#hire-escrow-states)) or `createJob` `failed`; `prepareFund` needs `open`; `prepareComplete` needs `submitted`; `prepareReject` needs `open` (a cancel before paying), `funded`, or `submitted` before `approvalDeadline`.
- Feedback methods raise `HireNotCompleted` when the hire is not `completed`, and `HireAlreadyRated` when the hire already has a `feedbackReference`. `HireAlreadyRated` is the only "already rated" code, synchronous or asynchronous.

### Agent catalog
Expand Down Expand Up @@ -108,15 +108,15 @@ Agent runtime failures after funding are not submission outcomes: they set `Hire

This applies to F4-6 (`register_full`), F4-7 (`set_agent_wallet`), F5-4 (escrow `create_job` and `fund`), the evaluator's `complete` and `reject`, and F7-4 (`give_feedback`).

1. **Prepare.** A `prepare…` method (or `createHire` for the first `create_job`) builds the unsigned envelope or authorization entry, simulates it, and returns a `PreparedTransaction` bound to the session wallet (`signer`), the network passphrase, and an expiry.
1. **Prepare.** A `prepare…` method (or `createHire` for the first `create_job`) builds the unsigned envelope or authorization entry, simulates it, and returns a `PreparedTransaction` bound to the session wallet (`signer`), the network passphrase, and an expiry. When the chain cannot be read or the simulation fails (for example `prepareFund` when the wallet has no USDC balance), the call raises `ChainUnavailable` with `details.reason = simulationFailed`.
- For transaction envelopes the session wallet is the transaction source, so its envelope signature is the only user signature needed. `transaction` is the envelope hash, fixed at preparation.
- For `setAgentWallet` the server also fixes, at preparation, the whole `set_agent_wallet` envelope around the entry: agent account as source, sequence number, fee, and time bounds that end no later than `signatureExpirationLedger`. Only the builder's entry signature is missing, so the final hash is determined by the prepared envelope plus that signature.
2. **Sign (client).** The wallet adapter signs the prepared XDR unchanged. The app sends the signed XDR, with the `preparationId`, to the matching `submit…` method.
3. **Verify.** The server rejects the submission synchronously unless it is exactly what it prepared:
- `PreparationNotFound`: unknown `preparationId`, or it belongs to another resource or wallet.
- `PreparationExpired`: the preparation can no longer be used (`details.reason`: `timeBounds` when the envelope time bounds have passed, or `superseded` when a newer preparation replaced it). This applies to every purpose, including `setAgentWallet`.
- `InvalidSignedEnvelope`: the XDR is empty, not base64, truncated, has trailing bytes, or is a fee-bump envelope.
- `EnvelopeMismatch`: the transaction body differs in any byte from the prepared body.
- `EnvelopeMismatch`: the transaction body differs in any byte from the prepared body. `details.field` names the first differing group: `contract`, `function`, `arguments`, `source`, `timeBounds`, or `other` (fee, sequence number, Soroban data, memo, or envelope type).
- `InvalidTransactionSignature`: no signature from the session wallet, or a signature that does not verify over the transaction hash for the configured network (`details.reason`: `missing`, `wrongSigner`, or `doesNotVerify`).
- For authorization entries, `InvalidAuthorizationEntry` replaces the three envelope checks, and `AuthorizationExpired` covers ledger expiry (see F4-7 below).
4. **Persist, then submit.** Only after verification, the server stores the final signed envelope and its hash as a `ChainSubmission` with `state = submitted`, and then calls RPC `sendTransaction`. For `setAgentWallet` the final envelope is the prepared envelope with the verified entry inserted and the server's agent-account signature added.
Expand All @@ -130,7 +130,7 @@ This applies to F4-6 (`register_full`), F4-7 (`set_agent_wallet`), F5-4 (escrow
- A new preparation for the same resource supersedes the previous one and reuses the signer's current sequence number, so at most one of them can be included on chain.
- `HireEndpoint.prepareFund` raises `PaymentAlreadySubmitted` when an earlier `fund` for the hire reached `SUCCESS` on chain, even if verification then failed. A new funding is never prepared once funds may have moved.

Server-side submission through `stellar_dart` is not yet proven (spike #68, recommendation 6). ADR-0003's fallback is a TypeScript sidecar behind the same `LedgerPort` adapter. This is an implementation risk for #18 and #19, not a contract change.
Server-side envelope handling through `stellar_dart` is proven: the #96 spike passed with stellar_dart 2.3.0 (decode and byte-identical re-encode, network transaction hash, and ed25519 signature verification; tests in `puls3_server/test/spike`). The fallback, if it is ever needed, is pure Dart: `package:crypto` + ed25519 with hand-written XDR behind the same `LedgerPort` adapter. This is not a contract change.

### Server-signed escrow calls

Expand Down Expand Up @@ -418,7 +418,7 @@ Both decisions were made by the product owner on 2026-09-30. They resolve the tw
- New codes: `PreparationNotFound`, `PreparationExpired`, `InvalidSignedEnvelope`, `EnvelopeMismatch`, `InvalidTransactionSignature`, `SubmissionRejected`, `SubmissionInProgress`, `PaymentAlreadySubmitted`, `AuthorizationExpired`.
- Removed codes, because the client no longer supplies transaction hashes or builds transactions, and pending is data: `InvalidTransactionHash`, `TransactionNotFound`, `TransactionPending`, `RegistrationMismatch`, `FeedbackAuthorizationPending`, `FeedbackDoesNotMatchHire`. ADR-0005 also removed the SAC verification codes and the feedback authorization codes.
- New shared models: `PreparedTransaction` and `ChainSubmission`.
- Implementation risk, not a contract change: Serverpod-side submission through `stellar_dart` is not yet proven. ADR-0003's fallback is a TypeScript sidecar behind the `LedgerPort` adapter.
- Not a contract change: the #96 spike proved `stellar_dart` 2.3.0 for envelope decode, hash, and signature verification (tests in `puls3_server/test/spike`). The pure-Dart fallback (`package:crypto` + ed25519 with hand-written XDR) stays behind the same `LedgerPort` adapter.

### B. Progress transport: polling for the MVP, Serverpod streaming later

Expand Down Expand Up @@ -448,6 +448,6 @@ Both decisions were made by the product owner on 2026-09-30. They resolve the tw
|---|---|---|
| Escrow interface: function signatures, job id type, events, a job read method, how auto-approval and the D4 invariant are enforced | #55 | Preparation, funding verification, and `approvalDeadline` depend on it |
| RPC providers other than SDF Testnet returning contract events and state | #30 | Funding verification reads the job and its event |
| Server-side submission with `stellar_dart` | #18, #19 | Relay implementation path; the fallback is a TypeScript sidecar. |
| Server-side submission with `stellar_dart` | #18, #19 | Relay implementation path; spike passed (stellar_dart 2.3.0, `puls3_server/test/spike`); the pure-Dart fallback is `package:crypto` + ed25519 with hand-written XDR behind the same adapter. |
| Wallet challenge signing (`signMessage`) for F1-3 | #25 | Session establishment is not yet proven with Freighter. |
| `give_feedback` arguments in the Stellar 8004 drop-in | #14 | `prepareFeedback` builds that call |
52 changes: 52 additions & 0 deletions puls3_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,58 @@ writes `[chain-tracker]` lines to stdout (info) and stderr (warnings).
| Variable | Default | Meaning |
|---|---|---|
| `PULS3_HIRE_JOB_DURATION_SECONDS` | none (required) | How long a new hire's escrow job stays valid: `HireService.createHire` sets the job's `expired_at` to now plus this many seconds, and `fund` is only accepted for a job with that same `expired_at`. A positive whole number; without it `createHire` fails with `HireConfigurationMissing`. The value is deferred (ADR-0005 D3), so no default is set. |
| `PULS3_ESCROW_PREPARATION_VALIDITY_SECONDS` | none (required) | How long an unsigned escrow envelope stays valid: its `maxTime` is now plus this many seconds, and `submitEscrowCall` answers `PreparationExpired` after it. A positive whole number. |
| `PULS3_STELLAR_INCLUSION_FEE_STROOPS` | none (required) | The inclusion fee, in stroops, of every prepared envelope; the simulated resource fee is added on top. A positive whole number. |
| `PULS3_PLATFORM_FEE_BPS` | none (required) | The `max_fee_bps` argument of `fund`, in basis points from 0 to 1000 (the contract's `MAX_FEE_BPS`). It must equal `NetworkConfig.platformFeeBps`. `0` is a valid value. |

The three relay keys have no default, fallback or placeholder. They are read
when an operation needs them, so a missing, empty, non-integer or out-of-range
value makes that call fail with `HireConfigurationMissing` naming the key
(`createHire` and the `prepare*` methods read the keys they need;
`submitEscrowCall` reads none) and never stops the server or affects the other endpoints.

### Hire endpoint sessions

`HireEndpoint` (`createHire`, `prepareCreateJob`, `prepareFund`,
`prepareComplete`, `prepareReject`, `submitEscrowCall`) resolves the caller
through the `SessionWallet` seam before doing any work. Until wallet sessions
exist (#25) the production binding, `FailClosedSessionWallet`, fails closed:
every call is rejected with `AuthenticationUnavailable`, so the hire methods
are not usable against a running server yet. Tests inject a fake with
`HireEndpoint.sessionWallet`. `InputTooLong` has no documented limit, so the
hire `input` length is not checked.

### End-to-end proof on testnet

`tool/e2e_relay_testnet.dart` creates and funds a hire on **testnet** using
only `createHire`, `prepareFund` and `submitEscrowCall`, signing the unsigned
XDR unchanged with a consumer key. It composes the real `HireEndpoint`, wiring,
codec, Soroban RPC client and chain tracker over the test PostgreSQL (test
mode, migrations applied), checks that a second submit of one preparation
returns the existing record, and that an envelope with altered time bounds
(a copy, never broadcast) is rejected with `EnvelopeMismatch`. It prints the
hire and job ids, both transaction hashes with Stellar Expert links and the
final status, and exits non-zero on any failed step.

The secret is read from `PULS3_E2E_CONSUMER_SECRET` and is never printed or
stored; feed it from the Stellar CLI in the same command. The relay values
below are for this run only (they are not defaults of the server); the chain
ids default to testnet and can be overridden with the `PULS3_STELLAR_*` keys.
The consumer needs XLM and testnet USDC of the allowed token, and the registry
needs an agent with a wallet (`PULS3_E2E_AGENT_ID` picks one; the cheapest
otherwise).

```bash
docker start puls3_server-postgres_test-1 puls3_server-redis_test-1
cd puls3_server
PULS3_E2E_CONSUMER_SECRET="$(stellar keys secret alice)" \
PULS3_E2E_CONSUMER_ADDRESS="$(stellar keys address alice)" \
PULS3_ESCROW_PREPARATION_VALIDITY_SECONDS=600 \
PULS3_STELLAR_INCLUSION_FEE_STROOPS=100000 \
PULS3_PLATFORM_FEE_BPS=0 \
PULS3_HIRE_JOB_DURATION_SECONDS=86400 \
dart run tool/e2e_relay_testnet.dart
```

## Prerequisites

Expand Down
Loading