Skip to content

Reverse conversion: convert any sNight amount the wallet holds - #15

Merged
acedward merged 3 commits into
mainfrom
codex/00010-shielded-night-reverse-any-amount
Sep 9, 2026
Merged

Reverse conversion: convert any sNight amount the wallet holds#15
acedward merged 3 commits into
mainfrom
codex/00010-shielded-night-reverse-any-amount

Conversation

@acedward

@acedward acedward commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

sNight → NIGHT conversion no longer requires "one exact sNight coin minted and retained by this browser". Any amount up to the wallet's sNight balance converts in one wallet approval, including sNight received from another wallet or minted in another browser or origin.

Why the old gate existed, and why it was wrong

Audit 00003 (frontend, finding F1) asserted that receiveShielded(coin) "requires the wallet to spend the exact existing coin commitment", citing the security suite's "a fabricated nonce cannot balance" vector. That reading is incorrect:

  • Compact receive adds a validation condition that the coin is present as an output addressed to this contract. The compiled circuit (src/managed/contract/index.js _receiveShielded_0) calls _createZswapOutput_0(coin, contractAddress); compact-runtime appends a contract-owned output to the local zswap state and midnight-js emits ZswapOutput.newContractOwned(coinInfo, …) in the unproven offer. Ledger v8 documents claimedShieldedReceives as "coin commitments (outputs) this contract call requires".
  • The wallet therefore only has to fund that output from the sNight it holds (inputs of that token type plus change). The nonce is chosen by the dApp and need not match an owned coin.
  • Every "cannot balance" vector in shielded-night.security.test.ts is explained by an insufficient sNight balance at that point in the test, not by the nonce.

On-chain proof (commit 1)

test/integration/shielded-night.reverse-any-amount.test.ts, run on the repository's local docker stack (midnight-node 1.0.0, indexer-standalone 4.3.3, proof-server 8.1.0) with retries disabled — passed first attempt, 323 s:

Scenario Result
mint N via convertToShielded ok
reverse N/2 with a fresh random nonce finalized — sNight N → N/2, NIGHT +N/2
reverse the remaining N/2 (the wallet's own change coin) with a fresh nonce finalized — sNight 0, NIGHT back to start
mint N twice, reverse 2N in one call with a fresh nonce finalized — the wallet merged two coins into the single contract-owned output
reverse 1 with 0 sNight rejected by the wallet: Wallet.InsufficientFunds: Insufficient funds; balances unchanged

The v2 (Stagenet) contract has the identical construction (contracts/v2/managed/contract/index.js _receiveShielded_0_createZswapOutput_0); no 2.x local stack exists in this repo, so that evidence is static.

App change (commit 2, plus a follow-up commit for message formatting)

  • frontend/protocols/shared/adapter-core.ts: the reverse path checks the requested amount against getShieldedBalances() first, then builds { nonce: random 32 bytes, color: wrapper, value: amount } and calls convertToUnshielded. The browser coin store is not consulted; an uncertain submission surfaces the transaction id with a warning that a blind retry converts more sNight.
  • SwapCard.tsx: enabled from the wallet total, Max = wallet total, "Wallet total: X sNight." replaces the exact-coin text; over-limit amounts are rejected with a formatted message before any wallet interaction. BalancePanel.tsx: the anomaly that fired when the wallet held less than this browser had minted is removed (it would misfire after every reverse).
  • Docs: frontend/README.md "Reverse coin limitation" rewritten; TESTING.md sharp-edge corrected; misleading comments in the security suite fixed (assertions unchanged).
  • Root unit suite: 131 → 133 tests (reverse cases rewritten: fresh nonce, no coin-store reads, over-limit rejection, uncertain-outcome id, cancellation). Frontend typecheck and build clean. CI-equivalent Docker gate (node:24.15.0-bookworm, bun 1.4.2, all four installs, typecheck, build, root unit tests) exit 0; chunk hashes identical to the host build.

Served-build check (stub wallet, no extension available here)

With a stub connector reporting 5 sNight on Preprod: the reverse form enables with "Wallet total: 5 sNight.", Max fills 5, 7 is rejected locally with the wallet total, and 2 reaches the circuit call (failed only at the stub's proving provider). Real-wallet (Lace) acceptance: preview deployment noted in the PR conversation.

Not a breaking change: contract sources, generated artifacts, frontend/.env and workflows are untouched; the reverse path strictly widens what is accepted. Merging auto-deploys production through deploy.yml once push CI is green (CI's integration job runs the new test).

Edward A. added 3 commits September 8, 2026 21:46
convertToUnshielded claims its coin as an output addressed to the contract, so the wallet funds it with ordinary shielded coin selection. The new integration test reverses half of a minted coin, then the wallet's own change coin, then the merged value of two separately minted coins - each with a fresh random nonce - and shows the only failure left is insufficient sNight.
The reverse swap no longer requires a coin this browser minted and kept. It builds a fresh-nonce coin for the requested amount, checks that amount against the wallet's sNight balance first, and lets the wallet fund the contract-owned output by its own coin selection. The swap card enables from the wallet total and the balance panel no longer raises an anomaly when the wallet holds less than this browser minted. Docs and the security-suite comments now describe the real requirement: enough sNight, not an exact coin.
The swap card now compares the amount against the wallet's sNight balance and reports the limit in sNight rather than base units. The adapter keeps the authoritative check for a stale balance, but runs it before the step and log callbacks so a locally rejected amount no longer leaves 'approve in wallet' in the activity log, and its message states that its numbers are base units.
@acedward

acedward commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Served-build verification of 76156a4 (stub 4.x connector, keys derived from a throwaway seed, getShieldedBalances reporting 5 sNight on Preprod):

  • Reverse form enables with "Wallet total: 5 sNight."; Max fills 5.
  • Entering 7 → "The wallet holds 5 sNight; enter an amount up to that total." with no wallet interaction and no "approve in wallet" log line.
  • Entering 2 → "convertToUnshielded — approve in wallet", then the SDK called the wallet's proving provider check and prove for key location midnight/zswap/output — the contract-owned output built from the fresh-nonce coin, i.e. the same path the on-chain test finalized.

Preview deployment of this commit for a Lace test: https://preview-00010.shielded-night.pages.dev (immutable https://2bcc8c70.shielded-night.pages.dev).

Note (pre-existing, not introduced here): when the stub's prove rejects, both directions end in an uncaught ledger-WASM unreachable panic with the card stuck on "Converting…". The forward path is untouched by this PR and behaves identically; tracked separately.

@acedward
acedward merged commit 2bb3283 into main Sep 9, 2026
8 checks passed
acedward added a commit that referenced this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant