Skip to content

feat(server): hire and pay endpoint with on-chain payment verification - #93

Merged
TOMOKI977 merged 9 commits into
mainfrom
feat/19-hire-pay-endpoint
Oct 7, 2026
Merged

TOMOKI977 merged 9 commits into
mainfrom
feat/19-hire-pay-endpoint

Conversation

@moises-cisneros

@moises-cisneros moises-cisneros commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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) and HirePaymentInstructions were removed, because a client-supplied hash let anyone claim a public funding transaction.

  • Domain verification (puls3_domain): verifyFunding, JobState, FundedJob, EscrowFunding, typed FundingRejection reasons, and the HireRepository port.
  • Ledger adapter (puls3_server): escrowFunding(TransactionHash) and feeBps on SorobanLedger; JobFunded event parsing and job reads through RPC simulation.
  • Persistence (puls3_server): migration for hire and hire_payment with unique indexes on hire_id, transaction_hash and job_id; ServerpodHireRepository with replay protection.
  • HireService (server-internal, no endpoint): createHire and the funding verification, covered by a test per rejection reason. It must only be fed hashes the server submitted itself.
  • Removed the deprecated direct payment rail parser. Rebased onto main; unrelated files from the earlier merge are gone.

Not in this PR

Verification

cd puls3_domain && dart test            -> 132 passed
cd puls3_server && dart test test/unit  -> 171 passed
dart analyze lib test                   -> No issues found!
serverpod generate                      -> clean

Recorded testnet evidence used by the fixture: escrow CBRD7A7MXINM7LREKCL3RMKRQ5UMLGKNHAEYY4JT7MVBBB7R5QV4TPE2, job 3, fund tx 43cd3e8455cafdc08d62a644b8f9dd9174994644b2bdd3e57eaa6893aa5c2437.

@moises-cisneros moises-cisneros added this to the Serverpod (Oct 14) milestone Oct 5, 2026
@moises-cisneros moises-cisneros added area: backend Serverpod endpoints and persistence type: feat New functionality P0 Blocks a deadline deliverable labels Oct 5, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying puls3 with  Cloudflare Pages  Cloudflare Pages

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

View logs

@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.

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:

  1. Crear un hire con consumer igual a la dirección de la víctima.
  2. 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_FOUND del 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_SECONDS es obligatoria y no está documentada.
  • activeLedger as RegistryReader (hire_endpoint.dart:91) oculta una dependencia. Si se inyecta un LedgerPort que 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 de main. 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.

@TOMOKI977

Copy link
Copy Markdown
Contributor

@moises-cisneros, decidimos usar el relay del servidor (Decisión A en docs/architecture/api.md, #77) para los pagos. El servidor prepara la transacción, la wallet solo la firma sin modificarla, y el servidor la verifica, la envía y la sigue hasta que es final. Con ese diseño desaparecen los dos problemas críticos de este PR, porque el servidor ya no tiene que confiar en el hash ni en el consumer que manda el cliente.

La propuesta es reconvertir este PR, no descartarlo. La parte más delicada ya está hecha y testeada:

Se mantiene:

  • verifyFunding y FundedJob / JobState en puls3_domain.
  • La lectura del escrow en SorobanLedger (get_job, evento job_funded).
  • ServerpodHireRepository con los índices únicos de hire_payment.
  • Los tests de cada motivo de rechazo.

Se quita:

  • confirmPayment(hireId, txHash) y HirePaymentInstructions.
  • La parte del e2e donde el cliente arma y envía la transacción.
  • Los archivos ajenos al tema (docs/vision.md, docs/research/, demo-agents.json), rebaseando sobre main.

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í.

@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.

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

  1. Ten rejection tests assert nothing. checkRejection is declared void ... async and called without await (hire_service_confirm_test.dart:96-192), so each test body returns before the expectLater and the post-conditions run. Make it Future<void> and await (or return) every call.
  2. The job checks don't match api.md:255. The contract requires client and evaluator = Hire.consumer, expired_at as prepared, and state Funded. FundedJob has no evaluator or expiry, soroban_ledger.dart:132-141 drops them although EscrowJob decodes both, and verifyFunding accepts submitted and completed (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 via claim_refund while the hire stays paid.
  3. A second job_funded parser. ledger/job_funded_event.dart adds another JobFundedEvent next to main's in ledger/escrow_events.dart, plus a different contract_events.dart. They behave differently (yours doesn't require status == SUCCESS and swallows every exception), so after the merge the tracker and HireService could disagree on whether the same hire is paid. Please reuse escrow_events.dart.
  4. Migration order. 20261005000013469 sorts before main's chain_submission migrations (20261005222242476, 20261005231322835), and its definition snapshot has no chain_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_FOUND still surfaces as HirePaymentRejected(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 for verdict as FundingAccepted (:191), which a switch over the sealed type would avoid.
  • transactionReplayed and jobAlreadyBound can't be produced by verifyFunding, and reason and code are passed side by side in 20 places, so they can drift.
  • serverpod_hire_repository.dart:142 maps any unknown unique violation to hireId, which hides unexpected constraints.
  • openspec/specs/hire-payment/spec.md still specifies createHire returning payment instructions and currentFeeBps as MUST, for code that no longer exists.
  • PULS3_HIRE_JOB_DURATION_SECONDS isn't in puls3_server/README.md.

Happy to pair on the EscrowEffects wiring if that helps, since it's the piece the tracker is waiting for.

…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 force-pushed the feat/19-hire-pay-endpoint branch from 6ee477b to d89d582 Compare October 7, 2026 16:45
@moises-cisneros

Copy link
Copy Markdown
Contributor Author

@TOMOKI977 The rework is complete, rebased onto main, and ready for another round of review.

All 4 blocking issues and the non-blocking notes from your Oct 6 review have been fully addressed (including the missing awaits in tests, the unified parser, and the strict escrow funding validation). CI is completely green, and the Docker integration tests are passing.

Note on #77 alignment:
The domain was adapted to the new ERC-8183 lifecycle (open → funded), but there are 3 scope limitations deferred to #96:

  1. createHire persists the row before the create_job confirmation.
  2. findById currently only exposes open or funded. It does not yet derive expired, and the subsequent states (submitted, completed, rejected, expired) are not persisted yet.
  3. hire_view.status now exposes open or funded (instead of requested or paid), which is a visible change for clients.

@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.

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).
  • verifyFunding requires state funded and checks client and evaluator == consumer, provider, agent, token, budget and expired_at (funding.dart:94-118). Each rejection has a test.
  • It reuses firstJobFunded from escrow_events.dart, and the tracker only passes the event after a SUCCESS status.
  • The migration now sorts after the chain_submission ones, 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, verifyFunding rejects on state and 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.
  • createHire has no caller yet. When #96/#97 expose it, take consumer from 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.

@TOMOKI977
TOMOKI977 merged commit 8ce4cab into main Oct 7, 2026
8 checks passed
@TOMOKI977
TOMOKI977 deleted the feat/19-hire-pay-endpoint branch October 7, 2026 21:35
@TOMOKI977

Copy link
Copy Markdown
Contributor

@moises-cisneros merged as 8ce4cab, thanks for the thorough rework. The EscrowEffects.onFunded wiring is exactly what #96 needed. I'll recreate the Serverpod 4 upgrade-4-0 migration in #114 on top of yours, so there's nothing for you to do there. The two notes for #96 (the liveness edge and taking consumer from the authenticated caller) are in my last review.

TOMOKI977 added a commit that referenced this pull request Oct 7, 2026
…-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.
TOMOKI977 added a commit that referenced this pull request Oct 7, 2026
… 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
TOMOKI977 added a commit that referenced this pull request Oct 7, 2026
#93 reads it in HireService without listing it, which fails
scripts/tests/env-inventory.test.sh on main.

Refs #110
moises-cisneros pushed a commit that referenced this pull request Oct 7, 2026
)

* 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
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.

2 participants