From 86774f64d10effe1b8fb41db2f2f925edf160958 Mon Sep 17 00:00:00 2001 From: moises-cisneros Date: Thu, 8 Oct 2026 12:46:12 -0400 Subject: [PATCH 1/2] docs: describe the escrow relay and how to run it on testnet Lists ChainDataUnavailable for submitEscrowCall, drops the TypeScript sidecar from the ADRs and documents the config keys and the end-to-end run. Part of #96. --- docs/adr/0001-system-architecture.md | 2 +- docs/adr/0003-payment-rail-and-custody.md | 4 +- docs/architecture/api.md | 14 +++--- puls3_server/README.md | 52 +++++++++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/adr/0001-system-architecture.md b/docs/adr/0001-system-architecture.md index 53429bc0..acfb1bf1 100644 --- a/docs/adr/0001-system-architecture.md +++ b/docs/adr/0001-system-architecture.md @@ -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.)* diff --git a/docs/adr/0003-payment-rail-and-custody.md b/docs/adr/0003-payment-rail-and-custody.md index 067e8e12..a9b6833a 100644 --- a/docs/adr/0003-payment-rail-and-custody.md +++ b/docs/adr/0003-payment-rail-and-custody.md @@ -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:** @@ -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 diff --git a/docs/architecture/api.md b/docs/architecture/api.md index 247ae228..b05f02c6 100644 --- a/docs/architecture/api.md +++ b/docs/architecture/api.md @@ -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` | Yes | `InvalidStellarAddress`, `WalletMismatch`, `PersistenceUnavailable` | | `FeedbackEndpoint` | `getFeedbackEligibility` | `hireId: int`, `client: String` | `FeedbackEligibility` | Yes | `InvalidHireId`, `InvalidStellarAddress`, `WalletMismatch`, `HireNotFound`, `HireNotOwned`, `HireNotCompleted`, `HireAlreadyRated` | @@ -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 @@ -108,7 +108,7 @@ 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. @@ -116,7 +116,7 @@ This applies to F4-6 (`register_full`), F4-7 (`set_agent_wallet`), F5-4 (escrow - `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. @@ -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 @@ -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 @@ -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 | diff --git a/puls3_server/README.md b/puls3_server/README.md index e4f5bb8e..6b16b39a 100644 --- a/puls3_server/README.md +++ b/puls3_server/README.md @@ -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 From beba06766eed535be230ec946dcccdb0014e4f05 Mon Sep 17 00:00:00 2001 From: moises-cisneros Date: Thu, 8 Oct 2026 12:46:12 -0400 Subject: [PATCH 2/2] test(server): add the testnet end-to-end relay script Runs the real endpoint, services, codec, RPC client and chain tracker against testnet. The consumer key stays in the process environment. Part of #96. --- puls3_server/tool/e2e_relay_testnet.dart | 306 +++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 puls3_server/tool/e2e_relay_testnet.dart diff --git a/puls3_server/tool/e2e_relay_testnet.dart b/puls3_server/tool/e2e_relay_testnet.dart new file mode 100644 index 00000000..fbd852ec --- /dev/null +++ b/puls3_server/tool/e2e_relay_testnet.dart @@ -0,0 +1,306 @@ +// Testnet end-to-end proof of the escrow relay (issue #96, acceptance +// criterion 1): a consumer creates and funds a hire using only the hire +// endpoint (createHire, prepareFund, submitEscrowCall) and signs the +// unsigned XDR it receives UNCHANGED. +// +// Everything on the server side is the real thing: the real `HireEndpoint` +// behind a `SessionWallet` seam, the real `HireWiring` (Soroban RPC client, +// agent catalog, `StellarEnvelopeCodec`, chain accounts, sender), the real +// chain tracker (`startChainTracker`) and PostgreSQL through a Serverpod +// session in test mode (migrations applied). Only the consumer wallet is +// played by this script: it signs with a key read from the environment. +// +// TESTNET ONLY. The consumer secret is read from PULS3_E2E_CONSUMER_SECRET, +// kept in memory, and never printed or written anywhere. See the "End-to-end +// proof on testnet" section of puls3_server/README.md for the exact command. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:puls3_domain/puls3_domain.dart' hide Hire, Payment; +import 'package:puls3_server/src/chain/chain_tracker_wiring.dart'; +import 'package:puls3_server/src/chain/serverpod_chain_submission_store.dart'; +import 'package:puls3_server/src/chain/submission_values.dart'; +import 'package:puls3_server/src/generated/endpoints.dart'; +import 'package:puls3_server/src/generated/protocol.dart'; +import 'package:puls3_server/src/hire/hire_endpoint.dart'; +import 'package:puls3_server/src/hire/hire_services.dart'; +import 'package:puls3_server/src/hire/serverpod_hire_repository.dart'; +import 'package:puls3_server/src/hire/session_wallet.dart'; +import 'package:puls3_server/src/ledger/stellar_envelope_codec.dart'; +import 'package:serverpod/serverpod.dart' show Serverpod, Session; +import 'package:stellar_dart/stellar_dart.dart' as stellar; + +const _testnetPassphrase = 'Test SDF Network ; September 2015'; +const _explorer = 'https://stellar.expert/explorer/testnet/tx'; +const _confirmTimeout = Duration(minutes: 3); + +/// The session seam of this run: every session is the consumer wallet. +final class _ConsumerWallet implements SessionWallet { + const _ConsumerWallet(this.address); + + final StellarAddress address; + + @override + Future requireLogin(Session session) async => address; +} + +Never _fail(String message) { + stderr.writeln('FAIL: $message'); + exit(1); +} + +void _step(String message) => stdout.writeln('\n== $message'); + +void _ok(String message) => stdout.writeln(' ok: $message'); + +void _check(bool condition, String message) { + if (!condition) _fail(message); + _ok(message); +} + +/// [unsigned] carrying one signature of [key] over its transaction hash. The +/// transaction body is not touched: only the empty signature list at the end +/// of the envelope is replaced. +String _sign( + stellar.StellarPrivateKey key, + StellarEnvelopeCodec codec, + String unsigned, +) { + final bytes = base64Decode(unsigned); + final signature = key.sign(codec.parse(unsigned).hash); + final out = BytesBuilder() + ..add(bytes.sublist(0, bytes.length - 4)) // the empty signature list + ..add([0, 0, 0, 1]) + ..add(signature.hint) + ..add([0, 0, 0, 64]) + ..add(signature.signature); + return base64Encode(out.takeBytes()); +} + +Future _until( + String what, + Future Function() probe, { + Duration timeout = _confirmTimeout, +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final value = await probe(); + if (value != null) return value; + await Future.delayed(const Duration(seconds: 2)); + } + _fail('timed out waiting for $what'); +} + +Future main(List args) async { + final secret = Platform.environment['PULS3_E2E_CONSUMER_SECRET']; + if (secret == null || secret.isEmpty) { + _fail('PULS3_E2E_CONSUMER_SECRET is not set (see README).'); + } + final key = stellar.StellarPrivateKey.fromBase32(secret); + final consumer = StellarAddress.parse(key.toPublicKey().toAddress().address); + final expected = Platform.environment['PULS3_E2E_CONSUMER_ADDRESS']; + if (expected != null && expected != consumer.value) { + _fail('the secret does not belong to PULS3_E2E_CONSUMER_ADDRESS.'); + } + + final env = { + ...Platform.environment, + 'PULS3_TRACKER_ENABLED': 'true', + 'PULS3_TRACKER_INTERVAL_SECONDS': + Platform.environment['PULS3_TRACKER_INTERVAL_SECONDS'] ?? '2', + }; + final codec = StellarEnvelopeCodec.forPassphrase(_testnetPassphrase); + + _step('Preconditions'); + final wiring = HireWiring.fromEnvironment(environment: env); + _ok('network: ${wiring.stellar.networkPassphrase}'); + _check( + wiring.stellar.networkPassphrase == _testnetPassphrase, + 'the passphrase is the testnet one (this script never runs on mainnet)', + ); + _ok('consumer (public): ${consumer.value}'); + _ok('escrow: ${wiring.stellar.escrow.value}'); + _ok('usdc sac: ${wiring.stellar.usdcSac.value}'); + final catalog = await wiring.catalog.list(); + final hireable = [ + for (final agent in catalog) + if (agent.wallet != null) agent, + ]..sort((a, b) => a.priceUsdcStroops.compareTo(b.priceUsdcStroops)); + _check(hireable.isNotEmpty, 'the registry lists an agent with a wallet'); + final requestedAgent = int.tryParse( + Platform.environment['PULS3_E2E_AGENT_ID'] ?? '', + ); + final agent = requestedAgent == null + ? hireable.first + : hireable.firstWhere( + (a) => a.registryId == requestedAgent, + orElse: () => _fail('agent $requestedAgent has no wallet'), + ); + _ok( + 'agent: registry id ${agent.registryId} "${agent.name}" ' + 'price ${agent.priceUsdcStroops} stroops wallet ${agent.wallet}', + ); + + final pod = Serverpod( + ['--mode', 'test', '--apply-migrations'], + Protocol(), + Endpoints(), + ); + await pod.start(); + final session = await pod.createSession(); + final tracker = startChainTracker(pod, env); + if (tracker == null) _fail('the chain tracker did not start'); + // The seam is the only way to bind a wallet until #25 provides sessions; + // the setter is annotated for tests, and this run plays the wallet. + // ignore: invalid_use_of_visible_for_testing_member + HireEndpoint.sessionWallet = _ConsumerWallet(consumer); + final endpoint = HireEndpoint(); + final hires = ServerpodHireRepository(session); + final submissions = ServerpodChainSubmissionStore(session); + + Future confirmed(String preparationId) async { + final done = await _until( + 'submission of $preparationId to finish', + () async { + final stored = await submissions.findByPreparation(preparationId); + if (stored == null) return null; + if (stored.state == SubmissionState.failed) { + _fail( + 'submission ${stored.transactionHash} failed: ${stored.errorCode}', + ); + } + return stored.state == SubmissionState.confirmed ? stored : null; + }, + ); + return StoredSubmissionView(done.transactionHash, done.explorerUrl); + } + + try { + _step('createHire'); + final requestId = 'e2e-${DateTime.now().millisecondsSinceEpoch}'; + final created = await endpoint.createHire( + session, + agent.registryId, + consumer.value, + 'e2e testnet proof for issue #96', + requestId, + ); + final hireId = created.hire.id; + final createPrepared = created.preparedCreateJob; + _check(createPrepared != null, 'createHire returned a preparedCreateJob'); + _ok('hire id: $hireId, status: ${created.hire.status}'); + _ok('create_job preparation: ${createPrepared!.preparationId}'); + final unsignedCreate = createPrepared.unsignedTransactionXdr!; + + _step('tamper check (a copy; nothing is broadcast)'); + final tampered = base64Decode(_sign(key, codec, unsignedCreate)); + tampered[71] ^= 0x01; // last byte of the time bounds maxTime + try { + await endpoint.submitEscrowCall( + session, + hireId, + createPrepared.preparationId, + base64Encode(tampered), + ); + _fail('the tampered envelope was accepted'); + } on Puls3ApiException catch (e) { + _check( + e.code == 'EnvelopeMismatch' && e.details?['field'] == 'timeBounds', + 'tampered time bounds rejected: ${e.code} ${e.details}', + ); + } + _check( + await submissions.findByPreparation(createPrepared.preparationId) == null, + 'nothing was stored or sent for the tampered envelope', + ); + + _step('submitEscrowCall create_job (signed unchanged)'); + final signedCreate = _sign(key, codec, unsignedCreate); + final createDetail = await endpoint.submitEscrowCall( + session, + hireId, + createPrepared.preparationId, + signedCreate, + ); + final createHash = createDetail.escrowSubmission!.transaction; + _check( + createHash == createPrepared.transaction, + 'the relayed hash equals the prepared hash', + ); + final again = await endpoint.submitEscrowCall( + session, + hireId, + createPrepared.preparationId, + signedCreate, + ); + _check( + again.escrowSubmission!.transaction == createHash, + 'a second submit of the same preparation returns the existing record', + ); + _ok('create_job tx: $createHash'); + + _step('tracker confirms create_job'); + await confirmed(createPrepared.preparationId); + final opened = await _until('the hire to open with a job id', () async { + final row = await hires.findHire(hireId); + return row?.jobId != null && row?.status == HireStatus.open ? row : null; + }); + _ok('hire ${opened.id} is ${opened.status!.name}, job id ${opened.jobId}'); + + _step('prepareFund and submitEscrowCall fund (signed unchanged)'); + final fundPrepared = await endpoint.prepareFund(session, hireId); + _ok('fund preparation: ${fundPrepared.preparationId}'); + final signedFund = _sign(key, codec, fundPrepared.unsignedTransactionXdr!); + final fundDetail = await endpoint.submitEscrowCall( + session, + hireId, + fundPrepared.preparationId, + signedFund, + ); + final fundHash = fundDetail.escrowSubmission!.transaction; + _check( + fundHash == fundPrepared.transaction, + 'the relayed hash equals the prepared hash', + ); + _ok('fund tx: $fundHash'); + + _step('tracker confirms fund'); + await confirmed(fundPrepared.preparationId); + final funded = await _until('the hire to be funded', () async { + final row = await hires.findHire(hireId); + return row?.status == HireStatus.funded ? row : null; + }); + + _step('RESULT'); + stdout + ..writeln('hire id: ${funded.id}') + ..writeln('job id: ${funded.jobId}') + ..writeln('create_job prep: ${createPrepared.preparationId}') + ..writeln('fund prep: ${fundPrepared.preparationId}') + ..writeln('create_job tx: $createHash') + ..writeln(' $_explorer/$createHash') + ..writeln('fund tx: $fundHash') + ..writeln(' $_explorer/$fundHash') + ..writeln('payment tx: ${funded.paymentTransaction}') + ..writeln('final status: ${funded.status!.name}'); + _check( + funded.paymentTransaction == fundHash, + 'the recorded payment is the relayed fund transaction', + ); + } finally { + await tracker.stop(); + await session.close(); + await pod.shutdown(exitProcess: false); + } + exit(0); +} + +/// What the script needs of a confirmed submission. +final class StoredSubmissionView { + const StoredSubmissionView(this.transactionHash, this.explorerUrl); + + final String transactionHash; + final String? explorerUrl; +}