feat(onchain): transactional outbox and durable slashing saga (#396 #397) - #542
Merged
james2177 merged 2 commits intoSep 30, 2026
Conversation
frienzy514-png
force-pushed
the
feat/onchain-outbox-and-slashing-pipeline-396-397
branch
from
September 28, 2026 22:30
ef9121e to
da4665a
Compare
|
@frienzy514-png Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
…r-vortex-protocol#396 stellar-vortex-protocol#397) Outbox (stellar-vortex-protocol#396): - onchain_outbox table; intent create/accept/fill/cancel and the mirroring outbox row commit in one Prisma transaction (IIntentsUnitOfWork) - OutboxRelayService: FOR UPDATE SKIP LOCKED claims, per-intent ordering, signed envelope hash persisted before submit, attempts-fenced writes, exponential backoff, dead-lettering with metric + alert, dry-run aware - TxConfirmationService, live StellarTxService.invokeContract submit path - POST /api/v1/admin/outbox/:id/requeue Slashing saga (stellar-vortex-protocol#397): - pending_slashes table, detected -> challenge_window -> submitted -> confirmed | cancelled, exactly-once via unique intent_id - challenge window, on-chain re-verification with clock-skew tolerance, solver fill-proof and admin cancellation, compensation via rollbackPenalty - sweeper now only detects; runbook docs/runbooks/slash-cancellation.md Also fixes SorobanModule's missing imports and the e2e stellar-sdk mock. Closes stellar-vortex-protocol#396 Closes stellar-vortex-protocol#397
frienzy514-png
force-pushed
the
feat/onchain-outbox-and-slashing-pipeline-396-397
branch
from
September 28, 2026 22:45
da4665a to
e88dfb5
Compare
Author
|
Heads-up for maintainers:
This branch has exactly the same |
…-and-slashing-pipeline-396-397 # Conflicts: # .env.example # .env.mainnet.example # prisma/schema.prisma # src/config/configuration.ts # src/config/env.validation.ts # src/intents/intents.gateway.ts # src/intents/intents.module.ts # src/intents/intents.service.spec.ts # src/intents/intents.service.ts # src/metrics/metrics.service.ts # src/soroban/fill-verifier.service.spec.ts # src/soroban/fill-verifier.service.ts # src/soroban/solver-registry.service.spec.ts # src/soroban/soroban.module.ts # src/soroban/tx-confirmation.service.spec.ts # src/soroban/tx-confirmation.service.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #396
Closes #397
Summary
#396: transactional outbox for on-chain writes. Intent mutations no longer submit Soroban transactions inside the HTTP request.
create/acceptIfOpen/fillIfAccepted/cancelIfOpennow write the intent change and anonchain_outboxrow in a single Prisma$transaction.OutboxRelayServicesubmits the rows afterwards.#397: end-to-end slashing saga. The sweeper now only detects missed fills. A durable
pending_slashessaga (detected → challenge_window → submitted → confirmed | cancelled) holds each slash for a challenge window. When the window ends it re-verifies on-chain that no fill landed, then submits throughSolverRegistryService.slashSolver. Each cancellation compensates exactly once throughrollbackPenalty.Design
Outbox (#396)
onchain_outbox (id, intent_id, operation, payload, status, attempts, next_attempt_at, tx_hash)20260928000001_onchain_outbox. Also storesenvelope_hash,locked_untilandlast_errorIIntentsUnitOfWork(src/intents/intents.unit-of-work.ts). A lost race enqueues nothing. Payloads are encoded to contract args at enqueue time, so bad input fails the request instead of creating a poison rowSKIP LOCKEDWITH … FOR UPDATE SKIP LOCKED … UPDATE … RETURNINGstatement (prisma-outbox.repository.ts)intent_id, parallel across intentsconfirmed/simulatedSUCCESS→ confirm without resubmitting,NOT_FOUND→ rebuild. This is safe because the lease (120 s) outlives the tx time bound (30 s). All post-claim writes are fenced onattemptsdeadwith alertOUTBOX_MAX_ATTEMPTS:dead,vortex_outbox_dead_total, alert rule, and the intent stays blocked untilPOST /api/v1/admin/outbox/:id/requeueTxConfirmationService.check()next to #394'swaitForConfirmation, so workers never block a tick.StellarTxService.invokeContract(the #394 pipeline) only gains an optionalbeforeSubmit(hash)hook, called after signing and before broadcast. ASUCCESSfrom the live path confirms the row immediatelyKillSwitchActiveExceptionputs the row back without consuming an attempt, so an emergency pause can never dead-letter rowsSlashing saga (#397)
detected → challenge_window → submitted → confirmed | cancelledpending_slashes+SlashingPipelineService. Every step is a conditional transition, so a crash resumes from the stored stateFillVerifierServicescans the settlement contract'sintent_filledevents. If the RPC check fails, the saga retries later and never submits blindSLASH_CHALLENGE_WINDOW_SECONDS.POST /api/v1/slashes/:intentId/fill-proof(solver-signed, verified on-chain) andPOST /api/v1/admin/slashes/:intentId/cancelintent_id. Checked under concurrent detection against real Postgrescancel()runs only on the winning→ cancelledtransition:rollbackPenalty(with a durable fallback if the in-memory penalty was lost on restart), intent →filled/expired, audit entry, and anintent_slash_cancelledWS eventfillDeadline + SLASH_CLOCK_SKEW_TOLERANCE_SECONDSslash/onchaindefers the slash without consuming attempts, so the saga never gives up during a pausedocs/runbooks/slash-cancellation.mdAdmin routes (
/api/v1/admin/slashes,/api/v1/admin/outbox/:id/requeue) use the existingAdminGuardRBAC (x-admin-key/ADMIN_API_KEYS). Each action is written toadmin_audit_logbefore it is applied, and the authenticated admin's id is the actor.New env vars
OUTBOX_RELAY_ENABLED,OUTBOX_RELAY_INTERVAL_MS,OUTBOX_RELAY_BATCH_SIZE,OUTBOX_MAX_ATTEMPTS,OUTBOX_LEASE_SECONDS,SLASH_CHALLENGE_WINDOW_SECONDS,SLASH_CLOCK_SKEW_TOLERANCE_SECONDS, andSLASH_MAX_SUBMIT_ATTEMPTS. Each one is inenv.validation.ts,configuration.tsand all four.env*.examplefiles.check:env-driftreports no drift for any of them.Docs
The architecture doc (
onchain-settlement.md) has new outbox and saga sections.on-call.mdhas a new Scenario G (dead/backlogged outbox) and config table rows. Prometheus rules are indocs/runbooks/alerts/onchain-writes.rules.yml, and there's a CHANGELOG entry.Testing
Everything was fully verified on the base the branch was written against (before rebasing), with typecheck and all suites green:
slashing-pipeline.service.ts98.7% (target ≥95%),outbox-relay.service.ts100%,outbox.repository.ts95.8%,prisma-outbox.repository.ts91.7%,outbox-operations.ts92.3% (target ≥90%).markSubmitted(confirmed, not resubmitted); an envelope that never landed (rebuilt after the lease); a lease lost before broadcast (nothing sent).claimDuecalls return disjoint, head-of-intent-only rows; a throw inside the unit of work rolls back both the intent and the outbox row; concurrent detection yields onepending_slashesrow; lease/fencing behaves as specified;migration.sqlmatchesschema.prisma;down.sqlremoves both tables and enums.After rebasing onto current
main(0e6fcb3)maindoesn't currently build (see the PR comment), so full-suite results aren't meaningful yet. What I could verify:maintsc --noEmiterrors.tsfiles I added or changed introduce no type errors)stellar-signature.ts/env.validation.ts)prisma validatecheck:env-driftfor new keysmainThe other 9 suites of mine fail to compile only on errors already on
main:intents.service.tslines 151/581/713,soroban.service.tsgetLedger,stellar-tx.service.tsduplicateOperationimport,stellar-signature.tsparse errors. Oncemaincompiles they should run as before, and I'll rebase and confirm.Notes for reviewers
create_intent,accept_intent,fill_intent,cancel_intent) are provisional until the settlement ADR (Write an ADR for the on-chain settlement architecture #19) lands.SolverRegistryService's submit path is still gated pending Wire solver accept() to the solver-registry contract #23. Until then, slashes end atsubmittedwithsimulated=true, and the runbook documents this.SlashResultgains afailedflag so the saga can tell "retry" apart from "gated/simulated".ONCHAIN_DRY_RUN=trueend assimulatedand are not replayed when dry-run is later switched off.INTENTS_PERSISTENCE. With the defaultmemory, behaviour is in-process only.