feat(core): sponsor-wallet state-machine helpers (closes #22)#23
Conversation
Ships runtime helpers around the existing sponsor-wallet schemas so every AIBTC service (relay, settle, sponsor) drives off the same primitives instead of re-implementing nonce-conflict logic inline. - Schemas: HiroSponsorTxView, SponsorLedger, SponsorLedgerEntry, LastOccupant, QuarantinedNonce, OccupantClassification, BroadcastDecision. Extend WalletCapacity with optional quarantinedNonces and possibleNextNonce. - Helpers (pure, injectable clock): classifyOccupant, decideBroadcast, adoptOrphan, quarantine, reconcile. - Workflow tests cover the seven acceptance cases from issue #22: in-ledger RBF, orphan adopt, foreign quarantine, ghost untraceable, fee_too_low rebid, max-attempts quarantine, reconciliation drift. - README gains a "Consuming sponsor wallet helpers" section. Closes #22. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a small, pure(ish) “sponsor wallet state machine” runtime layer on top of the existing core schemas so downstream services can converge on the same nonce-conflict / RBF / quarantine workflow.
Changes:
- Introduces new runtime helpers (
classifyOccupant,decideBroadcast,adoptOrphan,quarantine,reconcile) plus supporting schemas/types (Hiro view + sponsor ledger). - Extends
WalletCapacitywith optional quarantine tracking (quarantinedNonces,possibleNextNonce) and addslastOccupantmetadata to occupied nonces. - Adds workflow-focused tests and README documentation for the canonical classify → decide → apply → reconcile cycle.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/sponsor-wallet-machine.test.ts | New workflow tests covering the issue’s acceptance cases for classification/decision/adoption/quarantine/reconcile. |
| src/core/wallet-capacity.ts | Adds LastOccupant, quarantined nonce schemas, and optional fields to WalletCapacity. |
| src/core/sponsor-wallet-machine.ts | Implements the new sponsor-wallet helper functions and their Zod discriminated unions. |
| src/core/sponsor-ledger.ts | Adds SponsorLedger/SponsorLedgerEntry schema and getLedgerEntry helper. |
| src/core/index.ts | Exports the new core modules from the package’s core entrypoint. |
| src/core/hiro-tx.ts | Adds minimal Hiro tx “view” schema (.passthrough()) for helpers. |
| package.json | Adds exports subpaths for the new core modules. |
| package-lock.json | Lockfile refresh (includes removals of some optional deps). |
| README.md | Documents the canonical consumption pattern and imports for the helpers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Sponsor ledger schema: add superRefine to enforce `Number(key) === entry.nonce`, so helpers that trust the record key can't drift from the entry payload. - classifyOccupant: attribute foreign sponsored txs to the sponsor (who holds the sponsor nonce) rather than the sender. - reconcile: stop silently adopting orphans at `fee: "0"` when `fee_rate` is missing. Surface them on a new `unpriceableOrphans` list instead so callers can quarantine/alert — matches the `decideBroadcast` guard. - wallet-capacity comment: replace the misleading "default to []/assignmentHead" wording with a description of the back-compat contract consumers implement. - README: correct the "no clock reads" claim to reflect that helpers default to `new Date()` when `now` is omitted. Adds three new tests covering key/nonce drift rejection, foreign-sponsor attribution, and the unpriceable-orphan path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The initial feature branch commit inadvertently stripped transitive peer-dep entries (@emnapi/*) from the lockfile — residue from a temporary dev dep that was installed and removed locally. No dependency changes were introduced by this PR, so the lockfile should be identical to main. `npm ci` now succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
arc0btc
left a comment
There was a problem hiding this comment.
Centralises sponsor-wallet nonce-conflict logic into pure, composable helpers — exactly the right fix for the inline duplication that was spreading across relay, settle, and sponsor services.
What works well:
- The classify → decide → apply → reconcile cycle is clean and well-documented. The README example gives consumers a working mental model immediately.
HiroSponsorTxView.passthrough()is the right call — it lets consumers pass the raw Hiro response without coupling to@stacks/blockchain-api-clientas a runtime dep.SponsorLedgerSchema.superRefinecross-validating key↔entry nonce is defensive in exactly the right place — prevents key/value drift bugs that would be silent otherwise.- Back-compat via optional
quarantinedNonces+possibleNextNoncemeans existingWalletCapacitypayloads parse without migration. - 25 new workflow tests covering all 7 acceptance cases from issue #22. The fixture helpers (
makeWallet,makeLedger,hiroTx) make the tests easy to read.
[suggestion] bump adds only 1 µSTX — may be insufficient for real RBF (src/core/sponsor-wallet-machine.ts)
The bump function increments by exactly 1 µSTX. Stacks mempool policy may require a minimum percentage fee increase for RBF to be accepted. The caller can add their own bump on top, but the current API makes this non-obvious — a caller who uses the returned fee directly will likely get a fee_too_low rejection. Consider accepting a minimum floor or documenting clearly that the caller must add their own margin on top of the returned fee.
[question] adoptOrphan defaults abandonAfter to now (src/core/sponsor-wallet-machine.ts:306)
const abandonAfter = options.abandonAfter ?? now;When abandonAfter is omitted, it's set to the current timestamp, meaning the relay can consider the nonce immediately eligible for abandonment on first check. This may be intentional (the orphan is already old), but it's a sharp edge: callers who don't pass abandonAfter will get an already-expired window for new occupied entries. Is the intent that relay always passes abandonAfter explicitly, or that "adopt at now" is a valid default?
[suggestion] reconcile drops detection is narrow (src/core/sponsor-wallet-machine.ts:415-420)
The dropped-detection loop only runs for nonces present in mempoolReadByNonce. A ledger entry whose nonce wasn't included in the mempool read is silently ignored. This is noted as "address-filtered" by design, but a caller who queries a partial nonce range could miss dropped entries. Worth adding a JSDoc line noting that dropped only covers keys present in mempoolReadByNonce.
[nit] decideSponsorConflict has an unlabeled fallback RBF path (src/core/sponsor-wallet-machine.ts:271-274)
if (!occupant) {
if (ledgerEntry) {
return { kind: "rbf_with_fee", nonce, fee: bump(ledgerEntry.fee) };
}
...
}This triggers when Hiro returns no mempool hit but the ledger has an entry — effectively "we think we broadcast this, but Hiro can't see it." Returning rbf_with_fee here means the relay will attempt to RBF without mempool confirmation. A comment would help readers understand this is the "assumed still in mempool" path, not a classified-occupant path.
Code quality notes:
bumpwould read more clearly asbumpFeeto distinguish from array/index bumping elsewhere in the file.- The
reconcilefunction does two distinct passes (adopt orphans, detect drops) that could be factored into named helpers for readability if the function grows, but current length is fine. - No dead code or unused imports introduced.
Operational context:
We've seen nonce gaps [2920, 2921] on the x402-relay sponsor wallet (tracked in our MEMORY). This PR is the missing primitive that makes those gaps recoverable — specifically, the quarantine + possibleNextNonce advancement lets the relay hop over a stuck nonce without manual intervention. The reconcile function also addresses the case we've observed where a sponsor tx appears in the mempool but wasn't recorded in the local ledger (the "orphan" class). Shipping 0.8.0 from this will unblock the relay refactor we've been waiting on.
Reviewed post-merge (already MERGED at time of this review). No action needed; commenting for the record.
Summary
classifyOccupant,decideBroadcast,adoptOrphan,quarantine,reconcile. All pure, injectablenow.HiroSponsorTxView,SponsorLedger,SponsorLedgerEntry,LastOccupant,QuarantinedNonce,OccupantClassification,BroadcastDecision.WalletCapacitygains optionalquarantinedNonces+possibleNextNonce(back-compatible).Closes #22. Gates the
aibtcdev/x402-sponsor-relayrefactor on a new minor (0.8.0) once release-please picks this up.Design notes
z.recordwith regex-validated keys)..passthrough()zod schema — structurally compatible with@stacks/blockchain-api-client'sTransaction/MempoolTransactionwithout adding a runtime dep.fee_rate, no caller fee hint) is quarantined rather than silently RBF'd at 1 µSTX — caller surfaces it explicitly.quarantinedNoncesandpossibleNextNonceare optional so existingWalletCapacitypayloads still parse.Test plan
npm run typechecknpm test— 197 passing (25 new workflow tests covering the 7 acceptance cases in the issue)npm run build