Skip to content

Fix multisig partial spends#661

Merged
0xisk merged 8 commits into
OpenZeppelin:mainfrom
andrew-fleming:fix-multisig-change
Jul 8, 2026
Merged

Fix multisig partial spends#661
0xisk merged 8 commits into
OpenZeppelin:mainfrom
andrew-fleming:fix-multisig-change

Conversation

@andrew-fleming

@andrew-fleming andrew-fleming commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Resolves #656.

This PR proposes to remove the change routing logic because sendShielded already routes the change coin to the contract. Implementing contracts can use the ShieldedSendResult to decide what to do with the change: persist it and pass it back on the next spend, or spend it onward to a different recipient in the same tx via sendImmediateShielded

The redundant re-send is dropped at all three sites:

  • ShieldedTreasury._send records the live change coin
  • ShieldedTreasuryStateless._send and ForwarderPrivate._drain return it untouched for the caller to handle

This PR also adds simulator regression tests that assert on the recorded zswap i/o. The retained change coin's nullifier must not appear among the tx inputs since that's the one thing observable without a node. Note that this only can catch double spends within the same tx and should not be used by itself as a standalone testing pattern

Summary by CodeRabbit

  • New Features

    • Improved shielded transfer flows so any leftover “change” is now handled safely and can be used in later transactions.
    • Added support for routing returned change to a different recipient in supported multisig flows.
  • Bug Fixes

    • Fixed change handling to avoid accidental double-spends when sending partial amounts.
    • Sending the full balance now correctly produces no change output.

@andrew-fleming andrew-fleming requested review from a team as code owners July 7, 2026 22:05
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes a double-spend bug where change coins from sendShielded were re-spent via sendImmediateShielded before being persisted or returned, causing later spends to be rejected as double-spends. ForwarderPrivate, ShieldedTreasury, and ShieldedTreasuryStateless now return/store the untouched change directly. New Zswap test-inspection utilities, mock circuits/simulators for routing change onward, and regression tests validate change coin spendability.

Changes

Change coin double-spend fix

Layer / File(s) Summary
Zswap test inspection utilities
contracts/test-utils/zswap.ts, contracts/package.json
Adds helpers (zswapLocalState, zswapSnapshot, zswapDelta, isNonceSpent, bytesToHex) to inspect simulator Zswap inputs/outputs and nonce spend status; registers a package import alias for the new module.
ForwarderPrivate._drain fix
contracts/src/multisig/ForwarderPrivate.compact, contracts/src/multisig/test/mocks/MockForwarderPrivate.compact, contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts, contracts/src/multisig/test/ForwarderPrivate.test.ts
Removes re-spending of change via sendImmediateShielded in _drain, returning it untouched; adds drainAndRouteChange mock circuit/simulator for routing change onward and tests validating no double-spend and correct routing.
ShieldedTreasury._send fix
contracts/src/multisig/ShieldedTreasury.compact, contracts/src/multisig/test/ShieldedTreasury.test.ts
Directly inserts result.change.value into _coins instead of re-spending via sendImmediateShielded; tests verify change routes to the treasury address, isn't yet spent, and is consumable on a later _send.
ShieldedTreasuryStateless._send fix
contracts/src/multisig/ShieldedTreasuryStateless.compact, contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact, contracts/src/multisig/test/simulators/MockShieldedTreasuryStatelessSimulator.ts, contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts
Removes selfAsRecipient import and conditional re-spend of change, returning result as-is; adds _sendAndRouteChange mock circuit/simulator plus tests for partial/full sends, over-send failure, and routed change.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ShieldedTreasuryStateless
  participant sendShielded
  participant Ledger

  Caller->>ShieldedTreasuryStateless: _send(coin, recipient, amount)
  ShieldedTreasuryStateless->>sendShielded: spend coin, pay amount
  sendShielded-->>ShieldedTreasuryStateless: result{sent, change}
  Note over ShieldedTreasuryStateless: previously re-spent change via sendImmediateShielded (bug)
  ShieldedTreasuryStateless-->>Caller: returns result untouched (fixed)
  Caller->>Ledger: later spend using result.change
  Ledger-->>Caller: succeeds (no double spend)
Loading

Possibly related PRs

  • OpenZeppelin/compact-contracts#378: Touches the same multisig shielded transfer change-handling logic in ForwarderPrivate._drain, ShieldedTreasury._send, and ShieldedTreasuryStateless._send.
  • OpenZeppelin/compact-contracts#526: Introduced the ForwarderPrivate module whose _drain change-handling semantics are directly modified in this PR.

Suggested reviewers: pepebndc

Poem

A coin once spent came back to bite,
Nullifiers clashing in the night.
Now change stays whole, untouched, serene,
Spendable next time, no double-spend scene.
This rabbit hops with joy so bright — 🐰✨
Ledger's clean, and code's just right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: fixing multisig partial spends.
Linked Issues check ✅ Passed The PR addresses the change-coin double-spend bug for ShieldedTreasury, ShieldedTreasuryStateless, and ForwarderPrivate and adds regression tests.
Out of Scope Changes check ✅ Passed The added test utilities, mocks, and simulators directly support the multisig partial-spend fix and regression coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

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

🧹 Nitpick comments (1)
contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts (1)

90-153: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a genuine "persist change, then spend it later" test.

The suite proves the change coin isn't re-spent within the same tx (isNonceSpent) and can be legitimately routed onward in the same tx (_sendAndRouteChange), but never actually persists result.change.value and feeds it into a later _send call to prove it remains spendable — unlike ShieldedTreasury.test.ts's follow-up-spend test. This is the most direct proof of the bug fix and matches the PR's stated acceptance criterion of "deposit, partial spend, then spend of the change."

Doing so for the stateless variant requires qualifying the returned ShieldedCoinInfo with a valid mt_index for the second _send call — worth confirming whether current test-utils support deriving this for a freshly emitted, uncommitted coin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts` around lines
90 - 153, The stateless treasury test currently only verifies that change is not
double-spent in the same transaction, but it never proves the emitted change
coin can be spent later. Update the ShieldedTreasuryStateless test around
treasury._send to first capture result.change.value, then use that returned
ShieldedCoinInfo in a follow-up _send call to confirm the change remains
spendable in a later spend. If the stateless path needs a valid mt_index for the
second call, adjust the test setup/helpers so the freshly emitted coin can be
qualified correctly before reusing it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts`:
- Around line 90-153: The stateless treasury test currently only verifies that
change is not double-spent in the same transaction, but it never proves the
emitted change coin can be spent later. Update the ShieldedTreasuryStateless
test around treasury._send to first capture result.change.value, then use that
returned ShieldedCoinInfo in a follow-up _send call to confirm the change
remains spendable in a later spend. If the stateless path needs a valid mt_index
for the second call, adjust the test setup/helpers so the freshly emitted coin
can be qualified correctly before reusing it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d7bc5d9e-58dc-4817-b10d-87fe91d823e2

📥 Commits

Reviewing files that changed from the base of the PR and between c096e31 and 5f1195c.

📒 Files selected for processing (12)
  • contracts/package.json
  • contracts/src/multisig/ForwarderPrivate.compact
  • contracts/src/multisig/ShieldedTreasury.compact
  • contracts/src/multisig/ShieldedTreasuryStateless.compact
  • contracts/src/multisig/test/ForwarderPrivate.test.ts
  • contracts/src/multisig/test/ShieldedTreasury.test.ts
  • contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts
  • contracts/src/multisig/test/mocks/MockForwarderPrivate.compact
  • contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact
  • contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts
  • contracts/src/multisig/test/simulators/MockShieldedTreasuryStatelessSimulator.ts
  • contracts/test-utils/zswap.ts

changeCoin.value
);
_coins.insertCoin(disclose(color), changeCoin, Utils_selfAsRecipient());
_coins.insertCoin(disclose(color), result.change.value, Utils_selfAsRecipient());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): This stores result.change.value with no preceding receiveShielded (unlike _deposit). That's correct only if sendShielded auto-claims its self-routed change, so insertCoin yields a spendable QualifiedShieldedCoinInfo with a valid mt_index. Confirmed on a node? A live spend of the retained change would settle it (see the zswap.ts note).

sent by claude (dev3-midnight-basic-review)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on the local node...if preferred, I can create a new repo or something with the scripts to run so it's verifiable and not "just trust me, bro"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need we just need to push the live harness for running tests on local node.

* nullifiers) and outputs (coin commitments) a circuit produces in the
* simulator.
*
* The dry (in-memory) simulator does NOT enforce the ledger's nullifier set,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 followup (non-blocking): These helpers read the dry backend, so the new assertions prove only same-tx non-re-spend. The actual #656 failure was the next tx spending the retained change against a node's nullifier set. A live-backend regression — deposit → partial _send → spend the retained change on a fresh node — is what fully closes #656. (The PR body already flags this pattern isn't sufficient standalone; noting so the live test isn't lost.)

sent by claude (dev3-midnight-basic-review)

sim?: { circuitContext?: { currentZswapLocalState?: ZswapLocalState } };
};
}
)?._backend?.sim?.circuitContext?.currentZswapLocalState;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (if-minor): Reaches through two undocumented simulator layers — _backend.sim (sim is private on DryBackend) then .circuitContext. It fails loud (throws with a clear message) so it won't rot silently, but consider an upstream ask for a supported zswap-local-state accessor now that the simulator is dual-backend.

sent by claude (dev3-midnight-basic-review)

export const bytesToHex = (b: Uint8Array): string =>
Buffer.from(b).toString('hex');

const toHex = bytesToHex;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (if-minor): const toHex = bytesToHex is a second name for the same function in one small file — use bytesToHex directly in isNonceSpent and drop the alias.

sent by claude (dev3-midnight-basic-review)

* `sendShielded` routes the change back to this contract as a self-owned
* output; `_drain` returns it untouched via `result.change`. The
* implementing contract chooses what to do with it: leave it dwelling at
* the contract for a future drain, or spend it onward to a different

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 followup (non-blocking): This guidance ("spend it onward to a different recipient … via sendImmediateShielded") carries no recipient-type caveat, but for this module routing change to a ContractAddress republishes a contract address in cleartext — the exact leak the module forbids for parent. Add: onward change routing here must stay to a ZswapCoinPublicKey, same reason as the parent. (The generic treasuries don't carry the parent-privacy guarantee, so their identical wording is fine.)

sent by claude (dev3-midnight-basic-review)

@0xisk 0xisk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basic review — PR #661 (Fix multisig partial spends)

Inline comments cover one mechanism question, two followups, and two nitpicks. One more followup that can't be inline (the file isn't in this diff):

🔵 followup (non-blocking): preset docs. ShieldedMultiSigV2.execute and the forwarder preset drain now surface a live result.change to dApp integrators, but their @returns still say only "including any change." Mirror the base-module doc improvement: state that the caller now owns the returned change (persist-and-replay, or route onward) and must not treat it as auto-retained.

Verdict: ok — 0 blocking, 1 question, 3 followups, 2 nitpicks. Ready to merge; the live-backend regression (noted inline on zswap.ts) is the item that truly closes #656.

sent by claude (dev3-midnight-basic-review)

Thank you @andrew-fleming! LGTM! I will just wait to verify all on local live infra.

@0xisk 0xisk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! thank you @andrew-fleming! There will be a following PR that will have all the live infra testing.

@0xisk 0xisk merged commit 2935e05 into OpenZeppelin:main Jul 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants