Repository navigation
feat(server): hire and pay endpoint with on-chain payment verification - #93
Conversation
Deploying puls3 with
|
| Latest commit: |
706cf05
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://af95f706.puls3-4lw.pages.dev |
| Branch Preview URL: | https://feat-19-hire-pay-endpoint.puls3-4lw.pages.dev |
TOMOKI977
left a comment
There was a problem hiding this comment.
Hay partes muy bien hechas: la protección contra replay con índices únicos (hire_payment.spy.yaml:11-20) y el mapeo de SQLSTATE 23505 son correctos, cada rechazo tiene su test y los cambios de estado pasan por la máquina de estados del dominio. Igual pido cambios por dos problemas críticos:
1. Un pago ajeno se puede reclamar. confirmPayment (hire_endpoint.dart:62-69) no requiere login, y consumer lo envía el cliente en createHire. La única validación de quién pagó es job.client == hire.consumer (funding.dart:137). Las transacciones de fund son públicas, así que alguien puede:
- Crear un hire con
consumerigual a la dirección de la víctima. - Confirmarlo con el hash de la transacción de la víctima.
Su hire queda pagado sin pagar, y el hire real de la víctima queda rechazado con transaction_replayed. Hace falta autenticación (requireLogin) y vincular consumer con la wallet de la sesión.
2. Se reconstruye todo en cada request. _instance (hire_endpoint.dart:45-52) llama a buildDefaultService en cada llamada. Eso crea un http.Client nuevo que nunca se cierra, un SorobanLedger y un AgentCatalogService nuevos, así que la caché de 60 s de #88 nunca se usa en este flujo. Cada createHire vuelve a recorrer todo el registry por RPC. AgentEndpoint ya memoiza su servicio; aquí debería hacerse lo mismo.
Otros puntos:
NOT_FOUNDdel RPC justo después de enviar la transacción se trata como rechazo terminal (soroban_ledger.dart:115-123). Debería ser un estado pendiente reintentable.- Si el insert en la base falla después de verificar en la cadena (
hire_service.dart:249-251), el error se pierde sin log ni camino de recuperación. - El reintento de un hire ya pagado devuelve
jobId: null(hire_service.dart:137), distinto a la primera respuesta. PULS3_HIRE_JOB_DURATION_SECONDSes obligatoria y no está documentada.activeLedger as RegistryReader(hire_endpoint.dart:91) oculta una dependencia. Si se inyecta unLedgerPortque no la implementa, falla en runtime.- El PR incluye cambios ajenos al tema (
docs/vision.md,docs/research/,demo-agents.json) que llegaron por el merge demain. Conviene rebasear para que el diff quede limpio.
También hay que alinear el diseño con el contrato de #77: allí el servidor hace de relay (prepareFund + submitEscrowCall) y confirmPayment no existe. Lo definimos en #77 antes de seguir con este PR.
|
@moises-cisneros, decidimos usar el relay del servidor (Decisión A en La propuesta es reconvertir este PR, no descartarlo. La parte más delicada ya está hecha y testeada: Se mantiene:
Se quita:
Lo nuevo va en issues aparte:
Así este PR queda como la base de verificación on-chain que usan #96 y #97, y además se vuelve más chico y fácil de revisar. Conviene esperar a que se mergee #77 (ya rebaseado y alineado con el escrow) para tener la referencia fija. Si algo del contrato no cierra con lo que implementaste, coméntalo ahí. |
21158b8 to
128b785
Compare
78550a1 to
577b4c3
Compare
TOMOKI977
left a comment
There was a problem hiding this comment.
Thanks for the rework. Both critical points from the last round are resolved: with the public endpoint gone, nobody can bind someone else's fund transaction, and services are no longer rebuilt per request. I still need to request changes, mostly because #100 landed on main in the meantime and this branch now duplicates and diverges from it.
Blocking
- Ten rejection tests assert nothing.
checkRejectionis declaredvoid ... asyncand called withoutawait(hire_service_confirm_test.dart:96-192), so each test body returns before theexpectLaterand the post-conditions run. Make itFuture<void>andawait(orreturn) every call. - The job checks don't match
api.md:255. The contract requires client and evaluator =Hire.consumer,expired_atas prepared, and stateFunded.FundedJobhas no evaluator or expiry,soroban_ledger.dart:132-141drops them althoughEscrowJobdecodes both, andverifyFundingacceptssubmittedandcompleted(funding.dart:144-148). A job with a third-party evaluator or a short expiry would mark the hire paid, and a short expiry can then be refunded viaclaim_refundwhile the hire stays paid. - A second
job_fundedparser.ledger/job_funded_event.dartadds anotherJobFundedEventnext tomain's inledger/escrow_events.dart, plus a differentcontract_events.dart. They behave differently (yours doesn't requirestatus == SUCCESSand swallows every exception), so after the merge the tracker andHireServicecould disagree on whether the same hire is paid. Please reuseescrow_events.dart. - Migration order.
20261005000013469sorts beforemain'schain_submissionmigrations (20261005222242476,20261005231322835), and its definition snapshot has nochain_submission. After rebasing, recreate it on top so it's the latest.
Suggested direction
HireService has no caller, and its funding path doesn't fit the seam the tracker already calls: EscrowEffects.onFunded(StoredSubmission, JobFundedEvent) returning EffectResult (chain/escrow_effects.dart). I'd rebase onto main and turn the funding verification into that implementation, reusing main's parser and outcome codes (JobMismatch with details.field, JobEvidenceUnavailable). That's exactly what #96 needs, keeps one definition of "funded", and should shrink this PR a lot. The crash-between-chain-and-DB recovery then comes for free, because the tracker retries until the effect is applied.
Non-blocking
NOT_FOUNDstill surfaces asHirePaymentRejected(transaction_not_found), and a malformed hash maps to the same code. With the tracker owning confirmation this mostly goes away.- The idempotent paid retry still returns
jobId: null(hire_service.dart:130). registry ?? (ledger as RegistryReader)(hire_service.dart:23) is still a hidden cast; prefer an explicit dependency. Same forverdict as FundingAccepted(:191), which aswitchover the sealed type would avoid.transactionReplayedandjobAlreadyBoundcan't be produced byverifyFunding, and reason and code are passed side by side in 20 places, so they can drift.serverpod_hire_repository.dart:142maps any unknown unique violation tohireId, which hides unexpected constraints.openspec/specs/hire-payment/spec.mdstill specifiescreateHirereturning payment instructions andcurrentFeeBpsas MUST, for code that no longer exists.PULS3_HIRE_JOB_DURATION_SECONDSisn't inpuls3_server/README.md.
Happy to pair on the EscrowEffects wiring if that helps, since it's the piece the tracker is waiting for.
577b4c3 to
280d309
Compare
…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.
6ee477b to
d89d582
Compare
|
@TOMOKI977 The rework is complete, rebased onto All 4 blocking issues and the non-blocking notes from your Oct 6 review have been fully addressed (including the missing Note on #77 alignment:
|
TOMOKI977
left a comment
There was a problem hiding this comment.
Approved, great rework. All four blockers from the last round are resolved, and I checked each one against the code:
- The rejection cases are awaited now (
hire_escrow_effects_test.dart:244-253). verifyFundingrequires statefundedand checks client and evaluator == consumer, provider, agent, token, budget andexpired_at(funding.dart:94-118). Each rejection has a test.- It reuses
firstJobFundedfromescrow_events.dart, and the tracker only passes the event after a SUCCESS status. - The migration now sorts after the
chain_submissionones, and its snapshot includes them.
Moving verification into EscrowEffects.onFunded was the right call. Binding the hire through the server-relayed submission plus the unique indexes closes the replay and cross-hire cases, and every non-blocking note was handled too.
Two things for #96, neither blocks this PR:
- Liveness edge. The job is read when the tracker confirms, not when it is funded. If the tracker lags and the provider has already moved the job to
Submitted/Completed,verifyFundingrejects onstateand the hire stays open while the funds sit in escrow (your test comment on the recorded job 3 shows exactly this). It can't produce a wrongful paid hire, but #96 needs a reconciliation path for it. createHirehas no caller yet. When #96/#97 expose it, takeconsumerfrom the authenticated caller, never from a request parameter. That was the original attack in round one.
Merge order with #114 (Serverpod 4 upgrade): both PRs add a migration, so whichever merges second has to regenerate its own. I suggest merging this one first; I'll then recreate the upgrade-4-0 migration in #114 on top of yours, so you don't need to change anything.
|
@moises-cisneros merged as 8ce4cab, thanks for the thorough rework. The |
…-upgrade Brings in the hire and hire_payment tables from #93. The generated protocol files are regenerated with serverpod_cli 4.0.4 instead of resolved by hand. The forced upgrade-4-0 migration is removed here because its snapshot predates migration 20261006210458593; it is recreated in the next commit.
… the hire tables The forced upgrade-4-0 migration was created before #93 added migration 20261006210458593 with the hire and hire_payment tables, so its snapshot did not know about them. Recreate it with serverpod_cli 4.0.4 as 20261007214534790-upgrade-4-0 so it now sorts after 20261006210458593 and its definition includes hire, hire_payment, chain_submission and the Serverpod 4 module tables. The migration does not create, drop or alter the hire tables. The hand edit is re-applied: rows of serverpod_auth_idp_rate_limited_request_attempt are copied to a temporary table before Serverpod drops and recreates it, with nonce mapped to key, and restored afterwards, so rate-limit history still survives the upgrade. Refs #110
) * build(serverpod)!: upgrade runtime and generated APIs to 4.0.4 * fix(migrations): preserve auth rate-limit rows in Serverpod 4 upgrade Copy serverpod_auth_idp_rate_limited_request_attempt rows through a transaction-scoped temp table while the generator recreates the table to rename nonce to key. Document the upgrade, migration and rollback steps. Refs #110 * build(server): bundle Serverpod 4 server and keep local secrets out of the image Serverpod 4 dependencies use Dart build hooks, so the image now uses dart build cli and ships the emitted bundle. The new .dockerignore keeps .env files and config/passwords.yaml out of the build context, which previously baked local secrets into the runtime image. Refs #110 * ci: pin Flutter 3.44.4 / Serverpod 4.0.4 and build the server image Add a docker job, triggered by server, domain, lockfile and .dockerignore changes, that builds the image with planted secret files and asserts the bundle exists and passwords.yaml is excluded. Refs #110 * docs: update toolchain pins to Flutter 3.44.4 and Serverpod 4.0.4 Refs #110 * build(flutter): pin the Cloudflare Pages Flutter version in the repository The Pages build command cloned Flutter 3.41.4 from the dashboard, so every branch on Serverpod 4 (Dart ^3.12.2) failed version solving. The build now runs scripts/cloudflare-pages-build.sh, which installs the branch's pinned Flutter. A test keeps its version in sync with CI. Refs #110 * docs(env): list PULS3_FLUTTER_DIR in .env.example Refs #110 * fix(migrations): recreate the Serverpod 4 upgrade migration on top of the hire tables The forced upgrade-4-0 migration was created before #93 added migration 20261006210458593 with the hire and hire_payment tables, so its snapshot did not know about them. Recreate it with serverpod_cli 4.0.4 as 20261007214534790-upgrade-4-0 so it now sorts after 20261006210458593 and its definition includes hire, hire_payment, chain_submission and the Serverpod 4 module tables. The migration does not create, drop or alter the hire tables. The hand edit is re-applied: rows of serverpod_auth_idp_rate_limited_request_attempt are copied to a temporary table before Serverpod drops and recreates it, with nonce mapped to key, and restored afterwards, so rate-limit history still survives the upgrade. Refs #110 * docs(env): list PULS3_HIRE_JOB_DURATION_SECONDS in .env.example #93 reads it in HireService without listing it, which fails scripts/tests/env-inventory.test.sh on main. Refs #110 * ci: run the offline script tests on every change scripts/tests/*.test.sh were never run in CI, so main broke the env inventory (PULS3_HIRE_JOB_DURATION_SECONDS) without a red check. The new scripts job runs them all and gates the merge. Refs #110
Refs #19. Payments through the server relay are tracked in #96 and #97.
Summary
Server-side on-chain verification of escrow funding for hires (#19), the base that #96 and #97 build on. Per the review of this PR and the relay decision (Decision A in
docs/architecture/api.md, #77), there is no public hire endpoint here:confirmPayment(hireId, txHash)andHirePaymentInstructionswere removed, because a client-supplied hash let anyone claim a public funding transaction.puls3_domain):verifyFunding,JobState,FundedJob,EscrowFunding, typedFundingRejectionreasons, and theHireRepositoryport.puls3_server):escrowFunding(TransactionHash)andfeeBpsonSorobanLedger;JobFundedevent parsing and job reads through RPC simulation.puls3_server): migration forhireandhire_paymentwith unique indexes onhire_id,transaction_hashandjob_id;ServerpodHireRepositorywith replay protection.HireService(server-internal, no endpoint):createHireand the funding verification, covered by a test per rejection reason. It must only be fed hashes the server submitted itself.main; unrelated files from the earlier merge are gone.Not in this PR
prepare…andsubmitEscrowCallwithrequireLoginandconsumerbound to the session wallet (feat(server): escrow relay prepare and submit endpoints for hires #96).Verification
Recorded testnet evidence used by the fixture: escrow
CBRD7A7MXINM7LREKCL3RMKRQ5UMLGKNHAEYY4JT7MVBBB7R5QV4TPE2, job 3, fund tx43cd3e8455cafdc08d62a644b8f9dd9174994644b2bdd3e57eaa6893aa5c2437.