Skip to content

feat(server): chain submission store, sendTransaction and tracker for create_job and fund - #100

Merged
TOMOKI977 merged 9 commits into
mainfrom
feat/97-chain-submission-tracker
Oct 6, 2026
Merged

TOMOKI977 merged 9 commits into
mainfrom
feat/97-chain-submission-tracker

Conversation

@TOMOKI977

@TOMOKI977 TOMOKI977 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Update: #102 (the tracker, approved) was squash-merged into this branch as ee3babd, so this PR now ships the store, sendTransaction and the tracker together. PULS3_TRACKER_ENABLED stays off until #96.

Refs #97 (foundation; the tracker itself follows in a second PR)

Summary

Foundation for the escrow relay tracker, split out so #96 can build on it now:

  • Submission values (lib/src/chain/submission_values.dart): SubmissionPurpose, SubmissionState and outcome codes, with wire names exactly as in docs/architecture/api.md.
  • SorobanRpcClient.sendTransaction: returns a typed SendTransactionResult (PENDING, DUPLICATE, TRY_AGAIN_LATER, ERROR); transport errors become LedgerUnavailable.
  • chain_submission model and migration (additive only). Server-only columns (signedEnvelopeXdr, validUntil, hireId, send counters) never reach the client protocol.
  • ChainSubmissionStore: interface, Postgres implementation and an in-memory fake, with one contract test suite run against both. It's named Store because Serverpod already generates a ChainSubmissionRepository.

API for #96

abstract interface class ChainSubmissionStore {
  Future<StoredSubmission> insertSubmitted({required SubmissionPurpose purpose, required String transactionHash, required String signedEnvelopeXdr, required DateTime validUntil, String? preparationId, int? hireId, String? explorerUrl}); // throws ChainSubmissionConflict
  Future<StoredSubmission?> findByPreparation(String preparationId);
  Future<List<StoredSubmission>> listSubmitted({int limit = 100});
  Future<bool> markConfirmed(int id);           // only from submitted
  Future<bool> markFailed(int id, String code); // only from submitted
  Future<bool> recordSend(int id, DateTime at);
}

submitEscrowCall should call insertSubmitted (state submitted) before sendTransaction, per relay step 4.

Acceptance criteria (from #97, partial)

  • Submissions are persisted durably, one per preparation and one per transaction hash
  • A final state (confirmed / failed) is never overwritten (single conditional update)
  • Tracker polling, resend and restart recovery: next PR
  • create_job / fund confirmation effects: next PR

Verification evidence

  • dart analyze --fatal-infos: clean (server and client).
  • dart test: 195 passed (181 unit, 14 integration against Postgres via docker compose).
  • A second serverpod generate leaves no diff.
  • Pre-PR review: risk, reliability, resilience and readability lenses, no blocking findings. Non-blocking notes (JSON-RPC error classification, listSubmitted starvation, per-row isolation, recordSend guard) are addressed in the tracker PR, where they apply.

Notes for reviewers

@TOMOKI977 TOMOKI977 added this to the Stellar Elite (Oct 10) milestone Oct 5, 2026
@TOMOKI977 TOMOKI977 added area: backend Serverpod endpoints and persistence type: feat New functionality 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: c1c81de
Status: ✅  Deploy successful!
Preview URL: https://77cb4116.puls3-4lw.pages.dev
Branch Preview URL: https://feat-97-chain-submission-tra.puls3-4lw.pages.dev

View logs

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

Checked out the branch and ran it locally: dart analyze is clean and dart test passes (195 tests), including the integration suite against Postgres/Redis.

The migration is additive, duplicate-insert conflicts are mapped by index name, and the conditional UPDATE ... WHERE state='submitted' keeps a final state from being overwritten. No blockers from my side. Suggestions:

  • recordSend has no state='submitted' guard (serverpod_chain_submission_store.dart). A tracker holding a stale submitted row can bump sendAttempts / lastSentAt on a row that just became confirmed. It could be one UPDATE ... SET sendAttempts = sendAttempts + 1 WHERE id = $1 AND state = 'submitted' instead of read-then-write.
  • sendTransaction (soroban_rpc_client.dart) returns the node's hash without comparing it to the persisted transaction hash. Worth documenting that callers must check it, or taking the expected hash as a parameter.
  • markFailed(id, code) accepts any string, and it is returned to the client as errorCode. I see #102 adds the isKnown check, so this only matters if #100 lands alone.
  • Question on scope: SubmissionOutcomeCode.all has no AuthorizationExpired or HireAlreadyRated, which api.md lists for setAgentWallet / giveFeedback. I assume those arrive with their purposes, just confirming.
  • Starvation of listSubmitted (ordered by updatedAt, which recordSend does not bump) is already named in the PR as deferred. #102 addresses it with recordCheck.

…tted

recordSend locked the row but only checked that it exists, so a send recorded after a confirm or fail still bumped sendAttempts and lastSentAt of a final record. It now returns false unless the record is still submitted, in the Serverpod store and the in-memory fake alike.
@TOMOKI977

Copy link
Copy Markdown
Contributor Author

Thanks for running the suites locally, that's really helpful.

Suite after the fix: 197 passed (integration included).

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

Re-checked the updated branch locally: dart analyze is clean and dart test passes (197 tests, integration included). The recordSend guard in 526f3ad does what I asked, and the shared contract test covers both the in-memory fake and the Serverpod store. Thanks for the quick fix.

Approving. The sendTransaction hash check moving to #101 is fine with me.

TOMOKI977 and others added 3 commits October 6, 2026 13:35
* fix(server): keep JSON-RPC error code and message and type request rejections

* fix(server): rotate, isolate and validate chain submission store records

listSubmitted lists never-sent records first, then the least recently sent, so stuck records cannot starve the batch. An unreadable row is logged and failed with EscrowCallFailed instead of failing the whole batch. recordSend only counts sends of submitted records, insertSubmitted checks the purpose/preparation pairing and markFailed only accepts known outcome codes.

* test(server): record the testnet create_job and fund transactions of escrow job 3

getTransaction with xdrFormat json from soroban-testnet.stellar.org for create_job 3baba183... and fund 43cd3e84... (contracts/deployments/testnet.json).

* feat(server): parse escrow job_created and job_funded events from transactions

* feat(server): add the submission ledger port and its Soroban RPC adapter

* feat(server): add the escrow effects port with a no-op implementation

* feat(server): add the chain submission tracker pass

Each pass polls getTransaction for a batch of submitted records, resends the persisted envelope when due, expires records past their time bounds by chain time, and confirms createJob and fund through EscrowEffects. A record that cannot be read or updated stays submitted for the next pass without stopping the batch.

* test(server): cover tracker recovery of persisted submissions after a restart

* feat(server): add a periodic tracker loop without overlapping passes

* feat(server): start the chain submission tracker when PULS3_TRACKER_ENABLED is true

* fix(server): list chain submissions by last check so stuck records cannot starve the tracker

listSubmitted put never-sent rows first and skipped the sent-rows query when they filled the batch, so limit or more records the tracker never sends (untracked purposes, SUCCESS without an effect) starved createJob and fund records. Its two queries could also list one row twice.

Add a server-only lastCheckedAt column (set on insert, backfilled from createdAt) and index (state, lastCheckedAt, id). listSubmitted is now one query ordered by lastCheckedAt then id, and the tracker calls the new conditional recordCheck for every record a pass leaves submitted. recordSend keeps pacing resends only.

* fix(server): time out a hung tracker pass so later passes run

A pass that never completed, such as one stuck on a database call, kept the loop's in-flight marker set, so every later tick was skipped. TrackerLoop now takes a pass timeout, logs a timed-out pass as an error and lets the next tick start a new pass. The wiring uses a five minute timeout.

* fix(server): stop the tracker and close its http client on shutdown

server.dart dropped the loop startChainTracker returned, so it was never stopped and its HTTP client never closed. TrackerLoop now takes an onStop callback that runs after the pass in flight, the wiring uses it to close the client, and server.dart registers the loop's stop as a Serverpod shutdown task.
Recorded getTransaction fixtures include key_hash, the public SHA-256 of a ledger key in a TTL entry, which the generic-api-key rule flags. The allowlist matches only that JSON field with a 64-character hex value.
@TOMOKI977 TOMOKI977 changed the title feat(server): chain submission store and sendTransaction for the relay tracker feat(server): chain submission store, sendTransaction and tracker for create_job and fund Oct 6, 2026
@TOMOKI977
TOMOKI977 merged commit c549139 into main Oct 6, 2026
8 checks passed
@TOMOKI977
TOMOKI977 deleted the feat/97-chain-submission-tracker branch October 6, 2026 17:57
@TOMOKI977

Copy link
Copy Markdown
Contributor Author

Merged into main as c549139 (squash), with #102 included. Thanks @moises-cisneros for the two review rounds.

Before merging I added a narrow gitleaks allowlist for "key_hash": "<64 hex>" (c1c81de). The recorded testnet getTransaction fixtures carry public ledger key hashes that the generic-api-key rule flagged once this code reached a PR against main.

What this means for #96: the store, sendTransaction, the tracker and the EscrowEffects port are now on main, so you can build on them directly. Rebase onto main and recreate your migration on top of chain_submission's to avoid a migration_registry.txt conflict. PULS3_TRACKER_ENABLED stays off until #96 provides real, idempotent effects. #97 stays open for submit, release, claim_refund and the domain effects, and the hardening items are in #101.

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 type: feat New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants