Skip to content

fix(wallet-toolbox): a terminal rejection fails the action's pending children and keeps its unnamed inputs - #1080

Open
Calgooon wants to merge 2 commits into
bsv-blockchain:mainfrom
Calgooon:lane/toolbox-rejected-after-received
Open

Calgooon wants to merge 2 commits into
bsv-blockchain:mainfrom
Calgooon:lane/toolbox-rejected-after-received

Conversation

@Calgooon

@Calgooon Calgooon commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Program and scope

  • Tracker or issue: Fixes wallet-toolbox: an action REJECTED after RECEIVED leaves the actions funded from its change pending and quarantines its unnamed inputs; the balance does not read the chain #1079
  • Program gate(s) advanced: none
  • Why this change is needed: an action Arcade answered RECEIVED (202) and later REJECTED was failed alone. Actions already funded from its change stayed unproven, holding their other inputs and offering their change, until TaskReconcilePendingTransactions reached them an hour later. And an input-conflict rejection quarantined every input of the action, including the change a losing retry spent, although Arcade's text names the spent outpoint. On mainnet a phone wallet showed 6,654 sats against 11,657 on chain (txids in wallet-toolbox: an action REJECTED after RECEIVED leaves the actions funded from its change pending and quarantines its unnamed inputs; the balance does not read the chain #1079).
  • The change (storage/methods/reconcileFailedTransactionInputs.ts):
    • failPendingDescendants(terminalTxids, storage, trx): fails every pending local request (ProvenTxReqNonTerminalStatus) whose raw transaction spends an output of a terminal one, transitively:
      • the request becomes invalid, with a failedParentCascade history note;
      • its transaction becomes failed through updateTransactionStatus, which releases its inputs and makes its outputs unspendable;
      • quarantineReqInputsFromFailedParents re-quarantines the outputs it took from a failed parent.
        Completed or proven transactions are never touched.
    • quarantineReqInputs(..., evidence?: { extraInfo }): when the rejection text names <txid>:<vout> outpoints that are inputs of the action, only those local inputs are quarantined. When it names none, every local input is quarantined, as before.
    • TaskArcadeSSE.applyTerminalFailure and TaskReconcilePendingTransactions.applyTerminalVerdict pass the rejection text and run the cascade inside the same storage transaction as the terminal transition. Listeners hear REJECTED for each descendant.
  • Explicitly out of scope:
    • The 60-minute age gate on the reconcile poll, and whether a fresh terminal input-conflict verdict could skip it.
    • Restoring outputs that an earlier, unnamed quarantine already marked unspendable. No default task or specOp does this today.
    • TaskCheckForProofs ignoring an Arcade REJECTED it reads while looking for a proof.
  • Exact head SHA reviewed: 97e0a24

Impact

  • No public package source or manifest changed
  • Public package source or manifest changed; affected packages are listed below
  • Infrastructure source, dependency, image, or deployment configuration changed
  • Public API, exports, types, runtime targets, or browser/mobile behavior changed
  • Security-sensitive boundary changed
  • Documentation or examples changed

Affected packages: @bsv/wallet-toolbox, @bsv/wallet-toolbox-client, @bsv/wallet-toolbox-mobile (patch). The version bump, release note and changelog entry are intentionally deferred, as on #1078: #1076 takes these packages to 2.14.10, and an independent bump here would conflict with it. I will add them on whichever release carries this change.

Verification

  • Local commands and results (Node 24.11.1, pnpm 10.33.2, frozen install):
    • RED before the fix (commit 8fd9b1c3a), test/monitor/rejectedAfterReceived.test.ts, 2 of 4 failing:
      • "Arcade's REJECTED event fails the action, releases its inputs, and fails every action funded from its change": expect(await txStatus(jam, jam.rows.C)).toBe('failed'), received "unproven".
      • "the reconcile poll's terminal verdict does the same when the event was missed": expect(await output(jam, jam.outputs.M1)).toMatchObject({ spendable: true }), received spendable: false.
      • On the base sources the change balance reads 6,654 (event) and 8,404 (poll).
    • Green after the fix (commit 97e0a2452): 4 of 4, balance 11,657 on both paths. "A rejection that names no input of the action still quarantines every local input" passes before and after.
    • src/monitor and test/monitor: 17 suites, 178 tests pass. TaskArcSSE.test.ts's mock provider gains findProvenTxReqs (no pending requests), a method every StorageProvider has.
    • npx jest over the package with the test script's ignore patterns, after pnpm build: every suite passes except StorageKnexMigrationAtomicity ("Child exited before checkpoint"). That suite fails the same way on the base sources in this environment. syncTransferHttp (local HTTP sync) failed after 593 s in a separate run with the fix. Its cause was not examined, and it was not run on the base sources.
    • pnpm lint (package), tsc --noEmit -p tsconfig.json, and Prettier on the changed files: pass.
  • Hosted CI run: pending on this head.
  • Conformance evidence: not applicable (wallet storage lifecycle, not a wire rule).
  • Coverage delta: one new suite, 4 tests, on a real SQLite store.
  • I self-reviewed the complete diff for correctness, security,
    compatibility, public API, artifacts, dependencies, docs, and operations
  • All applicable checks are terminal and successful on the exact head; any
    scope-based skip is expected and validated by the merge gate

Security and dependencies

  • No dependency or lockfile change
  • CodeQL/negative tests cover any changed trust boundary. A rejection naming no input of the action still quarantines every local input. Named outpoints count only when they are inputs of the action. Descendants are found from their own raw transactions, so a completed or proven transaction is never failed.
  • No new override, advisory dismissal, quality suppression, or skipped test
  • Workflow permissions and lifecycle-script behavior remain least privilege

Release and operations

  • No npm publication was performed from a workstation or from this PR
  • Required npm patch bumps are included or intentionally deferred by the
    controlling program (deferred, see Impact)

Migration: none.

  • A wallet whose rejected action and children are still pending heals on the next terminal verdict: the SSE event, or the reconcile poll once the request is an hour old.
  • Outputs an earlier unnamed quarantine already marked unspendable are not restored by this change.

🤖 Generated with Claude Code

Calgooon and others added 2 commits October 14, 2026 14:12
…n's children and its live inputs jammed

The jam a phone wallet hit on Arcade: a retry R answered RECEIVED (202),
then REJECTED 466 "UTXO_SPENT ... H:0 utxo already spent by tx M"; a later
action C was funded from R's change and rejected as "parent rejected". The
wallet showed C's change (6,654) against 11,657 of change on chain.

On a real SQLite store:
- the SSE REJECTED event fails R, but C stays unproven and keeps the head
  M:0 and F:0 allocated, and C's change stays in the balance;
- the reconcile poll's terminal verdict fails R and C, but quarantines every
  input of R, so M:1 (unspent on chain, never named by Arcade) leaves the
  balance for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…children and keeps its unnamed inputs

An action Arcade answered RECEIVED (202) and later REJECTED failed alone.
An action already funded from its change stayed unproven, kept its other
inputs allocated and its change in the balance, until the reconcile poll
reached it an hour later; meanwhile the wallet kept funding new actions from
that dead change. And an input-conflict rejection quarantined every input of
the action, so the change a losing retry had spent left the balance for good
although Arcade named a different input as the spent one.

- failPendingDescendants: after a request turns terminal, fail every pending
  local request whose transaction spends one of its outputs, transitively:
  request invalid with a failedParentCascade note, transaction failed (its
  inputs released), the outputs taken from a failed parent re-quarantined.
  Run inside the terminal transition of TaskArcadeSSE and
  TaskReconcilePendingTransactions; listeners hear REJECTED for each.
- quarantineReqInputs takes the rejection's text; when it names outpoints
  that are inputs of the action (Arcade's "<txid>:<vout> utxo already spent
  by tx ..."), only those are quarantined. A rejection naming none still
  quarantines every local input.
- Regression on a real SQLite store (the beta jam): after R's REJECTED, by
  event or by poll, R and its child C are failed, M:0, M:1 and F:0 are
  spendable, and the change balance is 11,657 (was 6,654 by event, 8,404 by
  poll).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant