Repository navigation
feat(server): escrow relay prepare and submit endpoints for hires (#96) - #119
moises-cisneros wants to merge 21 commits into
Conversation
…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.
…ignature verification
…nd chain accounts port
…im and submission insert
… the job-created effect
… seam and lazy wiring
…elay to Serverpod 4.0.4
Deploying puls3 with
|
| 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 |
|
Hi Moises, thanks for pushing this. Before we start the review, two things need attention. 1. CI is red (3 checks)
2. Size
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. |
|
@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 So, for the demo:
If anything else is missing for the demo flow on your side, let me know here so we can plan around it. |
TOMOKI977
left a comment
There was a problem hiding this comment.
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
submitEscrowCallcompares 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 forclaim+insertSubmittedbacked 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)
FailClosedSessionWalletrejects every hire call in production until #25, so the deployed app cannot hire yet. The demo will run throughtool/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)
complete/rejectcan block a hire forever._untrackedPurposesmakes the tracker return beforelookup, so these records are never resent, never expired and never confirmed (chain_submission_tracker.dart:168,:262). Any later prepare then throwsSubmissionInProgress(escrow_relay_service.dart:136).prepareRejectis reachable fromopentoday. At minimum, the tracker should still resend and expire them, even without effects.- 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. - 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
ArgumentErrorfromcodec.build. Validate them at startup. onJobCreatedthrowsStateErroron a broken invariant, which keeps the recordsubmittedforever.onFundeduses a terminalJobMismatchin the same situation. Make them consistent.- There is no cleanup for
escrow_preparationrows, andChainUnavailable(simulationFailed)mixes retryable outages with terminal errors (it is also reused for the agent-wallet read). - Docs drift. The live
hire-paymentspec requiresAgentInactiveandInputTooLong, which are not implemented.prepareCompleteis documented as working but is unreachable, because_rownever derivessubmitted. ADR-0003 and api.md place envelope handling behindLedgerPortinstead ofEnvelopeCodec/ChainAccounts.PULS3_PLATFORM_FEE_BPS"must equal" aplatformFeeBpsthat does not exist in code. - Structure.
EscrowRelayServicemixes five responsibilities, andHireServicedepends on it only for pure mappers. The XDR transaction writer and its constants are duplicated betweenxdr_invoke_encoderandStellarEnvelopeCodec.HireViewis now dead. The ports expose ServerpodTransaction. Tests live in bothtest/<area>andtest/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.
|
Thanks for the review. I split this into a chain of PRs, tracked in #122 (draft, no merge):
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 |
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.
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 returnsCreateHireResult{hire, preparedCreateJob?}: a simulated, unsignedcreate_jobenvelope. The hire has no status untilcreate_jobis confirmed, andrequestIdis idempotent per session wallet (IdempotencyKeyReused).prepareCreateJob,prepareFund,prepareCompleteandprepareRejectreturn aPreparedTransaction. Preparations are persisted in a newescrow_preparationtable, and a new preparation supersedes the previous one.submitEscrowCall(hireId, preparationId, signedTransactionXdr)verifies that the signed envelope matches the preparation (EnvelopeMismatchwithdetails.field,InvalidSignedEnvelope,InvalidTransactionSignature,PreparationExpired), persists aChainSubmissionwithstate = submitted, then callssendTransaction. Chain outcomes never throw; a repeated submit returns the existing record.requireLoginfirst through an injectableSessionWallet. The production default fails closed withAuthenticationUnavailableuntil wallet sessions (feat: wallet connection in Flutter app #25) land.HireEscrowEffects.onJobCreatedis now real: it binds the job id once and opens the hire.mainand followsdocs/operations/serverpod-4-migration.md). Generated code and the migration were regenerated withserverpod_cli4.0.4.Acceptance criteria
prepare…andsubmitEscrowCall, with the wallet signing unchanged XDR (see the transactions below).test/ledger/envelope_codec_test.dart.consumeris not their session wallet (test/hire/hire_endpoint_test.dart).submit…call never throws for chain outcomes; they appear asChainSubmission.state = failedwith anerrorCode(test/hire/escrow_relay_submit_test.dart).ChainSubmission; a repeated submit returns the existing record (unit tests plus a Postgres race test intest/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):create_job89293b2b…3c9afundd255f14a…3b18Hire 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 withEnvelopeMismatch(timeBounds) before anything was sent. The same flow was first run on the Serverpod 3 base (create_job3a4bd3a8…67b3,fund2bdd4b77…cd59).How to rerun the e2e: see "End-to-end proof on testnet" in
puls3_server/README.md.Notes for reviewers
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 onmainthrough the squash, so the diff againstmainis only this work.PULS3_ESCROW_PREPARATION_VALIDITY_SECONDS,PULS3_STELLAR_INCLUSION_FEE_STROOPS,PULS3_PLATFORM_FEE_BPSand the existingPULS3_HIRE_JOB_DURATION_SECONDShave no default in code (ADR-0005 D3 defers the values). A missing key fails only the operation that needs it, withHireConfigurationMissing.stellar_dart2.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.mdand ADR-0001/0003 no longer name a TypeScript sidecar.escrow_preparationtable and therequestId,inputandjobIdcolumns plus two unique indexes onhire. Serverpod warns about the unique indexes; the columns are new and nullable, so existing rows cannot collide.getHireandlistHires, and the settlement ofcompleteandreject(submissions staysubmitteduntil the tracker handles them, feat(server): durable chain submission tracker with server-submitted escrow calls #97).SessionWallet(feat: wallet connection in Flutter app #25).InputTooLong(no documented limit),AgentInactive(indistinguishable fromAgentNotFound),PersistenceUnavailable.hire.jobIdinonFunded; todayverifyFundingand the preparedfundalready bind the job.submitEscrowCallcan raiseChainDataUnavailablewhen the agent catalog is down. It is raised before anything is stored or sent, andapi.mdnow lists it for that method.flutter analyzecould not run on the author's machine (Windows Developer Mode is off); only the server and domain suites were run.