Skip to content

feat(server): escrow relay prepare and submit endpoints for hires (#96) - #119

Closed
moises-cisneros wants to merge 21 commits into
mainfrom
feat/96-escrow-relay-endpoints
Closed

moises-cisneros wants to merge 21 commits into
mainfrom
feat/96-escrow-relay-endpoints

Conversation

@moises-cisneros

Copy link
Copy Markdown
Contributor

Closes #96

Summary

Implements the server relay for user-signed escrow calls (Decision A in docs/architecture/api.md, PR #77). The server prepares the unsigned transaction, the wallet signs it unchanged, and the server verifies and submits it.

  • HireEndpoint.createHire(agentId, consumer, input, requestId) now returns CreateHireResult{hire, preparedCreateJob?}: a simulated, unsigned create_job envelope. The hire has no status until create_job is confirmed, and requestId is idempotent per session wallet (IdempotencyKeyReused).
  • prepareCreateJob, prepareFund, prepareComplete and prepareReject return a PreparedTransaction. Preparations are persisted in a new escrow_preparation table, and a new preparation supersedes the previous one.
  • submitEscrowCall(hireId, preparationId, signedTransactionXdr) verifies that the signed envelope matches the preparation (EnvelopeMismatch with details.field, InvalidSignedEnvelope, InvalidTransactionSignature, PreparationExpired), persists a ChainSubmission with state = submitted, then calls sendTransaction. Chain outcomes never throw; a repeated submit returns the existing record.
  • Every hire method calls requireLogin first through an injectable SessionWallet. The production default fails closed with AuthenticationUnavailable until wallet sessions (feat: wallet connection in Flutter app #25) land.
  • HireEscrowEffects.onJobCreated is now real: it binds the job id once and opens the hire.
  • Built on Serverpod 4.0.4 (this branch merges main and follows docs/operations/serverpod-4-migration.md). Generated code and the migration were regenerated with serverpod_cli 4.0.4.

Acceptance criteria

  • The app can create and fund a hire on testnet using only prepare… and submitEscrowCall, with the wallet signing unchanged XDR (see the transactions below).
  • A signed envelope that differs from its preparation in any field is rejected, with a test per field group (contract, function, arguments, source, time bounds) in test/ledger/envelope_codec_test.dart.
  • A caller can never prepare or submit for a hire whose consumer is not their session wallet (test/hire/hire_endpoint_test.dart).
  • A submit… call never throws for chain outcomes; they appear as ChainSubmission.state = failed with an errorCode (test/hire/escrow_relay_submit_test.dart).
  • Each preparation has at most one ChainSubmission; a repeated submit returns the existing record (unit tests plus a Postgres race test in test/integration/escrow_preparation_store_test.dart).

Verification evidence

Testnet end to end (puls3_server/tool/e2e_relay_testnet.dart, run on Serverpod 4.0.4 with the real endpoint, services, codec, RPC client and chain tracker; the consumer is a throwaway testnet identity and its key never leaves the process environment):

Step Transaction Ledger
create_job 89293b2b…3c9a 5082571
fund d255f14a…3b18 5082572

Hire 1, job 5, final status funded. Each relayed hash equals the hash in the prepared envelope, a second submit of the same preparation returned the existing record, and a copy with altered time bounds was rejected with EnvelopeMismatch (timeBounds) before anything was sent. The same flow was first run on the Serverpod 3 base (create_job 3a4bd3a8…67b3, fund 2bdd4b77…cd59).

cd puls3_server
dart analyze --fatal-infos                                  -> No issues found
dart test test/unit test/protocol test/spike test/ledger test/hire  -> 567 passed
dart test test/integration   (Postgres test container)      -> 68 passed
cd ../puls3_domain && dart test                             -> 127 passed

How to rerun the e2e: see "End-to-end proof on testnet" in puls3_server/README.md.

Notes for reviewers

  • Size. This PR is well above the 400-line budget (roughly 2,000 hand-written lines of production code and several thousand more of tests and fixtures, plus generated Serverpod code and a migration). It needs size:exception. The commits are ordered for review: spike, doc reconciliation, protocol and migration, codec and RPC, preparation store, services, endpoint, verify fixes, e2e, archive, then the Serverpod 4 merge. The branch also carries older commits of the feat(server): hire and pay endpoint with on-chain payment verification #93 rework that are already on main through the squash, so the diff against main is only this work.
  • Config without defaults. PULS3_ESCROW_PREPARATION_VALIDITY_SECONDS, PULS3_STELLAR_INCLUSION_FEE_STROOPS, PULS3_PLATFORM_FEE_BPS and the existing PULS3_HIRE_JOB_DURATION_SECONDS have no default in code (ADR-0005 D3 defers the values). A missing key fails only the operation that needs it, with HireConfigurationMissing.
  • Fallback. stellar_dart 2.3.0 passed the spike (decode, byte-identical re-encode, network hash, ed25519 verify) on recorded testnet envelopes, so the pure-Dart fallback was not needed. api.md and ADR-0001/0003 no longer name a TypeScript sidecar.
  • Migration. The migration adds the escrow_preparation table and the requestId, input and jobId columns plus two unique indexes on hire. Serverpod warns about the unique indexes; the columns are new and nullable, so existing rows cannot collide.
  • Not in this PR.
  • submitEscrowCall can raise ChainDataUnavailable when the agent catalog is down. It is raised before anything is stored or sent, and api.md now lists it for that method.
  • flutter analyze could not run on the author's machine (Windows Developer Mode is off); only the server and domain suites were run.

…Repository port

verifyFunding checks a job read from the escrow against the hire in the
order of api.md: state exactly Funded, client and evaluator equal to the
consumer, provider, agent, token, budget and the prepared expired_at. Each
rejection names the mismatching job field.
hire_payment has unique indexes on hire_id, transaction_hash and job_id.
A unique violation of one of them maps to HirePaymentConflict by
constraint name; any other database error is rethrown. The migration is
generated on top of the chain submission migrations.
…tracker

HireEscrowEffects implements EscrowEffects.onFunded: it reads the job from
the escrow, checks it against the hire with verifyFunding and pays the hire
through Hire.pay, or fails with JobMismatch naming the job field or
JobEvidenceUnavailable. It reuses the shared JobFundedEvent parser and is
idempotent for the same submission. The tracker wiring uses it instead of
the no-op effects. HireService keeps createHire; the public payment
confirmation is gone because the tracker owns confirmation.
… effects

Also documents PULS3_HIRE_JOB_DURATION_SECONDS in the server README.
…bMismatch

Document that details.field is job_id for replay, wrong-transaction and duplicate-job conflicts.
Use Hire.fund and HireStatus.open/funded in the funding effect, the
Serverpod hire repository, the HireRepository port, tests and the
hire-payment spec after the lifecycle change in #107.
@moises-cisneros moises-cisneros added area: backend Serverpod endpoints and persistence type: feat New functionality P0 Blocks a deadline deliverable labels Oct 8, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying puls3 with  Cloudflare Pages  Cloudflare Pages

Latest commit: b0a4adc
Status: ✅  Deploy successful!
Preview URL: https://0c4348ce.puls3-4lw.pages.dev
Branch Preview URL: https://feat-96-escrow-relay-endpoin.puls3-4lw.pages.dev

View logs

@moises-cisneros moises-cisneros self-assigned this Oct 8, 2026
@TOMOKI977

Copy link
Copy Markdown
Contributor

Hi Moises, thanks for pushing this. Before we start the review, two things need attention.

1. CI is red (3 checks)

  • scripts: env-inventory fails because three new variables are missing from .env.example: PULS3_ESCROW_PREPARATION_VALIDITY_SECONDS, PULS3_PLATFORM_FEE_BPS, PULS3_STELLAR_INCLUSION_FEE_STROOPS. Adding them (with a short comment each) should fix it.

  • scan: gitleaks reports 4 generic-api-key hits. I checked them locally and all 4 look like false positives:

    • hire_relay_config.dart:16 (env var name constant)
    • generated/protocol.dart:805 and puls3_client/.../protocol.dart:226 (HireLedgerUnavailable class name)
    • test/fixtures/escrow_relay/simulate_create_job_3_base64.json:33 (ledger key XDR from testnet)

    Please don't disable the rule. Add a narrow allowlist entry to .gitleaks.toml (same style as the existing key_hash one) or a .gitleaksignore with the exact fingerprints, so real secrets still get caught.

  • gate will go green once those two pass.

2. Size
The PR is ~20k lines, and roughly 10.8k of them are authored code (excluding generated files, migrations and lockfiles). That is too much to review safely in one pass, especially for code that moves funds. Could you split it into chained PRs? A suggested order:

  1. Domain types and relay config (+ tests)
  2. prepare endpoint (+ tests and fixtures)
  3. submit endpoint (+ tests)
  4. Docs

Each PR should stay under ~400 authored lines where possible and link to the previous and next one. Happy to help with the split if needed.

@TOMOKI977

Copy link
Copy Markdown
Contributor

@moises-cisneros, change of plan on the split I asked for this morning. The Stellar Elite submission closes tomorrow (Oct 9) at 21:00 Bolivia time, and the demo's end-to-end flow depends on this PR: it is the only producer of submitted chain submissions for the tracker (#97), and it records the job id after create_job.

So, for the demo:

  1. Skip the split for now. Keep this PR as one piece. We'll track the split as tech debt after the submission.
  2. Fix CI only (the 3 missing .env.example variables and the narrow gitleaks allowlist from my previous comment). Please push that as soon as you can.
  3. Once CI is green, I'll review the parts that move funds first: submit, envelope validation and the ChainSubmission insert, then the rest. This runs on testnet, so I'll keep non-critical notes as follow-ups instead of blockers.

If anything else is missing for the demo flow on your side, let me know here so we can plan around it.

@TOMOKI977 TOMOKI977 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at b0a4adc with all four lenses (risk, reliability, resilience, readability). Short version: the funds path is solid, and I'll approve as soon as CI is green, as long as the new push only touches .env.example and the gitleaks allowlist. The rest is follow-up work, except for a few deployment points we handle on our side.

What's solid

  • submitEscrowCall compares the signed transaction body byte for byte against the prepared one, recomputes the hash server-side, and requires exactly one ed25519 signature from the session wallet. It also rejects fee-bump, non-V1 and trailing-byte envelopes.
  • Replay is blocked three ways: an idempotent preparationId, a single transaction for claim + insertSubmitted backed by unique indexes, and the signer/hire binding.
  • Ownership is checked on every method, and the session seam fails closed. The server never signs.
  • The claim/supersede race is tested against real Postgres, and the in-memory fakes are held to shared contract suites.
  • The migration is additive and sorts after main's upgrade-4-0.
  • The 4 gitleaks hits are false positives. No secret seed appears anywhere in the diff, and the e2e tool reads its secret from env.

Demo notes (no change needed in this PR)

  • FailClosedSessionWallet rejects every hire call in production until #25, so the deployed app cannot hire yet. The demo will run through tool/e2e_relay_testnet.dart. If you have a different plan, tell me here.
  • The chain tracker must be on in Cloud for submissions to be confirmed. Our deploy README says to leave it off. I'll fix that and set PULS3_TRACKER_ENABLED, together with the three new relay variables, when we deploy.

Follow-ups after the demo (I'll open an issue)

  1. complete/reject can block a hire forever. _untrackedPurposes makes the tracker return before lookup, so these records are never resent, never expired and never confirmed (chain_submission_tracker.dart:168, :262). Any later prepare then throws SubmissionInProgress (escrow_relay_service.dart:136). prepareReject is reachable from open today. At minimum, the tracker should still resend and expire them, even without effects.
  2. Sequence collisions. The sequence comes from chain state and the in-flight guard is per hire. Two hires from one wallet, or two concurrent prepares for one hire, get the same sequence and can leave two unsuperseded preparations (escrow_relay_service.dart:135-168, :421). There is no double-spend risk, only a confusing failure for one of them.
  3. Config is only validated lazily. The three new settings have no defaults, are read per request, and have no upper bound. A fee above uint32 surfaces as an unmapped ArgumentError from codec.build. Validate them at startup.
  4. onJobCreated throws StateError on a broken invariant, which keeps the record submitted forever. onFunded uses a terminal JobMismatch in the same situation. Make them consistent.
  5. There is no cleanup for escrow_preparation rows, and ChainUnavailable(simulationFailed) mixes retryable outages with terminal errors (it is also reused for the agent-wallet read).
  6. Docs drift. The live hire-payment spec requires AgentInactive and InputTooLong, which are not implemented. prepareComplete is documented as working but is unreachable, because _row never derives submitted. ADR-0003 and api.md place envelope handling behind LedgerPort instead of EnvelopeCodec/ChainAccounts. PULS3_PLATFORM_FEE_BPS "must equal" a platformFeeBps that does not exist in code.
  7. Structure. EscrowRelayService mixes five responsibilities, and HireService depends on it only for pure mappers. The XDR transaction writer and its constants are duplicated between xdr_invoke_encoder and StellarEnvelopeCodec. HireView is now dead. The ports expose Serverpod Transaction. Tests live in both test/<area> and test/unit/<area>.

Nice work on this one. The relay rules are implemented carefully, and the spec-to-test traceability makes it much easier to review.

@moises-cisneros

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I split this into a chain of PRs, tracked in #122 (draft, no merge):

  1. feat(server): escrow relay protocol models, migration and config (#96) #123 protocol models, migration and config. It also fixes the CI: the three variables are now in .env.example, and .gitleaks.toml has narrow allowlists scoped by file path and line shape (no rule disabled; I scanned the full tree with gitleaks 8.30.1 and found no leaks).
  2. feat(server): XDR invoke encoder and envelope codec contract (#96) #124 XDR encoder and codec contract
  3. feat(server): stellar envelope codec (#96) #125 stellar codec
  4. feat(server): escrow relay foundations: protocol, config, envelope codec and RPC (#96) #126 Soroban RPC calls
  5. feat(server): escrow preparation store (#96) #127 preparation store
  6. feat(server): hire lifecycle store (#96) #128 hire lifecycle store
  7. feat(server): persist preparations and prepare unsigned escrow envelopes (#96) #129 prepare
  8. feat(server): verify and relay signed escrow calls (#96) #130 submit
  9. feat(server): idempotent createHire and job binding (#96) #131 createHire and job binding
  10. feat(server): submit signed escrow calls and expose the relay in the hire endpoint (#96) #132 endpoint
  11. docs: escrow relay API, ADRs, README and testnet e2e (#96) #133 docs and testnet e2e
  12. docs: escrow relay API, ADRs, README, testnet e2e and OpenSpec archive (#96) #134 OpenSpec archive

Each PR targets the previous one, so review against its parent. Several slices are above ~400 authored lines because the tests of code that moves funds are most of it (for example 708 lines for the codec test). I did not trim tests to fit; they are marked as needing size:exception. Closing this one in favour of the chain.

@moises-cisneros
moises-cisneros deleted the feat/96-escrow-relay-endpoints branch October 8, 2026 17:09
TOMOKI977 pushed a commit that referenced this pull request Oct 8, 2026
Closes #96. Server-side relay (api.md Decision A): HireEndpoint with idempotent createHire, prepareCreateJob/prepareFund/prepareComplete/prepareReject that build unsigned Soroban envelopes, and submitEscrowCall that verifies the signed envelope byte for byte, checks the ed25519 signature against the session wallet, stores a submitted ChainSubmission and sends it for the chain tracker. Session wallet fails closed until #25. Includes the testnet e2e tool (tool/e2e_relay_testnet.dart), docs and the OpenSpec archive. Reviewed as #119; landed through the chain #126, #129, #132, #134.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend Serverpod endpoints and persistence P0 Blocks a deadline deliverable type: feat New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(server): escrow relay prepare and submit endpoints for hires

2 participants