From df694589bd1613a29d6f450db2838face8ce6cf0 Mon Sep 17 00:00:00 2001 From: "Edward A." Date: Tue, 8 Sep 2026 21:46:53 -0300 Subject: [PATCH 1/3] Prove reverse conversion needs no exact retained coin 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. --- .../shielded-night.reverse-any-amount.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/integration/shielded-night.reverse-any-amount.test.ts diff --git a/test/integration/shielded-night.reverse-any-amount.test.ts b/test/integration/shielded-night.reverse-any-amount.test.ts new file mode 100644 index 0000000..c61e359 --- /dev/null +++ b/test/integration/shielded-night.reverse-any-amount.test.ts @@ -0,0 +1,139 @@ +import * as ledgerV8 from '@midnight-ntwrk/ledger-v8'; +import { describe, expect, test } from 'vitest'; +import * as contract from '../support/shielded-night.js'; +import { describeContract } from '../support/describe-contract.js'; +import { categorizeError, timed } from '../support/instrumentation.js'; +import { tryCall } from '../support/smoke-helpers.js'; +import { + getCoinPublicKey, + getNightBalance, + getUserAddress, + randomBytes32, + tokenColorHex, + waitForShieldedBalance, + waitForUnshieldedBalance, +} from '../support/wallet-observations.js'; + +const N = 1_000_000n; // 1 NIGHT at 6 decimals +const NIGHT_HEX = ledgerV8.unshieldedToken().raw; + +/** + * Does `convertToUnshielded` require the caller to hand back an *exact* coin it + * previously minted, or only enough sNight for the wallet to fund the + * contract-owned output? + * + * Compact's `receive` adds a validation condition that the coin is present as an + * OUTPUT addressed to the contract (`_createZswapOutput_0(coin, contractAddress)` + * in the compiled circuit). The wallet then balances that output with ordinary + * shielded coin selection: any inputs of that token type totalling >= value, + * plus change. Nothing binds the coin's nonce to an owned UTXO. + * + * The frontend used to gate the reverse swap on a coin minted and retained by + * that browser, on the reading that the exact commitment had to be spent. These + * scenarios falsify that reading against a real ledger: + * + * 2. fractional — reverse HALF of one minted coin with a FRESH random nonce; + * 3. remainder — reverse the wallet's own change coin (a coin whose nonce the + * dApp never chose) with another fresh nonce; + * 4. merge — reverse the merged value of TWO separately minted coins in + * ONE call with a fresh nonce; + * 5. the only failure mode left is insufficient sNight. + * + * The evidence is the wallet's balances (shielded wrapper + unshielded NIGHT), + * not the circuit's private result: only the ledger can decide whether the + * transaction balanced. Fees are paid in DUST, so the NIGHT arithmetic is exact. + */ + +const step = async (label: string, fn: () => Promise): Promise => { + console.log(`[reverse-any-amount] START ${label}`); + const { value, ms } = await timed(fn); + console.log(`[reverse-any-amount] DONE ${label} (${ms}ms)`); + return value; +}; + +describe('shielded-night — reverse conversion for any wallet balance', () => { + describeContract(contract.factory, (ctx) => { + test( + 'convertToUnshielded balances from the wallet balance with a fresh nonce (fraction, change coin, merged coins); only insufficient sNight fails', + async () => { + const c = ctx(); + const deployed = await step('deploy', () => c.deployFresh([...contract.DEPLOY_ARGS])); + const color = (await contract.tokenColor(deployed)).private.result; + const colorHex = tokenColorHex(color); + const me = await getCoinPublicKey(c.walletCtx); + const myAddr = contract.rightUserAddress(getUserAddress(c.walletCtx).bytes); + + const night0 = await getNightBalance(c.walletCtx); + console.log(`[reverse-any-amount] night0=${night0} color=${colorHex}`); + expect(night0).toBeGreaterThanOrEqual(4n * N); + + // --- Scenario 1: mint N sNight in one coin. --------------------------- + await step('scenario 1 — convertToShielded(N)', () => + contract.convertToShielded(deployed, N, me, randomBytes32()), + ); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b >= N)).toBe(N); + expect( + await waitForUnshieldedBalance(c.walletCtx.wallet, NIGHT_HEX, (b) => b <= night0 - N), + ).toBe(night0 - N); + + // --- Scenario 2: reverse HALF of that coin with a FRESH nonce. -------- + // If the exact-coin reading were right, this could not balance: no owned + // UTXO has this nonce, and the value differs from every minted coin. + const half = N / 2n; + await step('scenario 2 — convertToUnshielded(fresh nonce, N/2)', () => + contract.convertToUnshielded(deployed, { nonce: randomBytes32(), color, value: half }, myAddr), + ); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b === half)).toBe(half); + expect( + await waitForUnshieldedBalance(c.walletCtx.wallet, NIGHT_HEX, (b) => b >= night0 - half), + ).toBe(night0 - half); + console.log('[reverse-any-amount] scenario 2 PASS: fractional reverse with a fresh nonce'); + + // --- Scenario 3: reverse the remainder — the wallet's own CHANGE coin. + // The N/2 now held was created by the wallet while balancing scenario 2; + // this browser never minted it and could not have retained it. + await step('scenario 3 — convertToUnshielded(fresh nonce, remainder N/2)', () => + contract.convertToUnshielded(deployed, { nonce: randomBytes32(), color, value: half }, myAddr), + ); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b === 0n)).toBe(0n); + expect(await waitForUnshieldedBalance(c.walletCtx.wallet, NIGHT_HEX, (b) => b >= night0)).toBe(night0); + console.log('[reverse-any-amount] scenario 3 PASS: change coin reversed with a fresh nonce'); + + // --- Scenario 4: two separate mints, reversed as ONE merged output. --- + await step('scenario 4a — convertToShielded(N) #1', () => + contract.convertToShielded(deployed, N, me, randomBytes32()), + ); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b >= N)).toBe(N); + await step('scenario 4b — convertToShielded(N) #2', () => + contract.convertToShielded(deployed, N, me, randomBytes32()), + ); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b >= 2n * N)).toBe(2n * N); + + await step('scenario 4c — convertToUnshielded(fresh nonce, 2N across two coins)', () => + contract.convertToUnshielded(deployed, { nonce: randomBytes32(), color, value: 2n * N }, myAddr), + ); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b === 0n)).toBe(0n); + expect(await waitForUnshieldedBalance(c.walletCtx.wallet, NIGHT_HEX, (b) => b >= night0)).toBe(night0); + console.log('[reverse-any-amount] scenario 4 PASS: two minted coins merged into one reverse'); + + // --- Scenario 5: with 0 sNight, even 1 unit cannot be funded. --------- + const outcome = await step('scenario 5 — convertToUnshielded(fresh nonce, 1) with 0 sNight', () => + tryCall(() => + contract.convertToUnshielded(deployed, { nonce: randomBytes32(), color, value: 1n }, myAddr), + ), + ); + expect(outcome.ok, 'expected the transaction to be rejected: the wallet holds no sNight').toBe(false); + if (!outcome.ok) { + const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error); + console.log(`[reverse-any-amount] scenario 5 rejection category=${categorizeError(outcome.error)}`); + console.log(`[reverse-any-amount] scenario 5 rejection message>>> ${message}`); + } + // The failed attempt changed nothing. + expect(await getNightBalance(c.walletCtx)).toBe(night0); + expect(await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b === 0n)).toBe(0n); + console.log('[reverse-any-amount] scenario 5 PASS: insufficient sNight is the only failure mode'); + }, + 20 * 60_000, + ); + }); +}); From 9dc50e588ff0a9995f8c26729a84d3617d9bbe6d Mon Sep 17 00:00:00 2001 From: "Edward A." Date: Tue, 8 Sep 2026 21:53:24 -0300 Subject: [PATCH 2/3] Reverse any sNight amount the wallet holds 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. --- TESTING.md | 13 ++- frontend/README.md | 8 +- frontend/protocols/shared/adapter-core.ts | 36 +++++--- frontend/src/App.tsx | 6 +- frontend/src/components/BalancePanel.tsx | 16 ++-- frontend/src/components/SwapCard.tsx | 21 +++-- .../shielded-night.security.test.ts | 36 +++++--- .../frontend-wallet-boundary.unit.test.ts | 85 +++++++++++++------ 8 files changed, 136 insertions(+), 85 deletions(-) diff --git a/TESTING.md b/TESTING.md index 5f5ea52..76cd727 100644 --- a/TESTING.md +++ b/TESTING.md @@ -207,8 +207,15 @@ spendable, corrupting wallet state), and the credit withdraws again cleanly. - `getBalance(secret)` **throws** for a never-used secret (`balances.lookup` without a `member` guard). Off-chain callers must probe `balances.member` first. Pinned by tests in both tiers. -- `depositShielded` requires the wallet to spend the *exact* `coin` passed as - the circuit argument. The round-trip test retains the coin returned by - `withdrawShielded` and passes it back verbatim. +- `depositShielded` and `convertToUnshielded` do **not** require the wallet to + own the `coin` passed as the circuit argument. `receive` claims that coin as + an output addressed to the contract, and the wallet funds it from its own + wrapper balance (inputs + change), so what is required is *enough sNight*, + not that exact coin — any nonce and any value up to the balance balances. + The round-trip test passes back the coin `withdrawShielded` returned because + that is convenient, not because it is necessary; + [test/integration/shielded-night.reverse-any-amount.test.ts](test/integration/shielded-night.reverse-any-amount.test.ts) + reverses fractions, a wallet change coin and two merged coins with fresh + random nonces, and shows the only failure mode is `Wallet.InsufficientFunds`. - The historical live e2e (pre-git, different monorepo) is documented in [README.md](README.md) under "Live status". diff --git a/frontend/README.md b/frontend/README.md index 3a7d4e2..919ffe1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -68,11 +68,13 @@ Changing the selector disposes the old protocol session, clears balances and ope The unfinished-swaps panel remains able to resume deposits created by the older two-step UI. Recovery uses the selected protocol adapter and refuses records belonging to another contract. -### Reverse coin limitation +### Reverse conversion and the coin store -The connector exposes shielded balances by token and amount, without the nonce of each owned coin. `convertToUnshielded` must receive that exact nonce, color and value, so the frontend persists the deterministic coin candidate before forward wallet interaction and makes it spendable only after the contract returns the same coin. Reverse conversion therefore spends one whole coin minted and retained by this browser. Valid records from the older v1 two-step UI migrate into the scoped v1 store. +`convertToUnshielded` claims its coin as an output addressed to the contract, and the wallet funds that output by ordinary shielded coin selection: inputs of the wrapper token totalling at least the amount, plus change. The nonce is chosen by this app and does not have to match a coin the wallet owns. Reverse conversion therefore works for **any amount up to the wallet's sNight balance**, whatever minted those coins — this browser, another browser, the other origin, or another wallet that sent them. The adapter builds a fresh random 32-byte nonce for the requested amount and checks the amount against `getShieldedBalances()` before any wallet interaction, so an over-large amount is reported as the wallet total instead of failing during balancing. -An uncertain forward or reverse submission keeps the coin record quarantined with its transaction id and network; it is not offered again automatically. A known pre-submission failure or wallet cancellation removes a pending forward candidate. sNight received from another wallet, previously minted by the atomic UI that discarded its result, or cleared from browser storage cannot be reversed until the wallet connector exposes coin-level details. +This is proven on chain, not inferred: [test/integration/shielded-night.reverse-any-amount.test.ts](../test/integration/shielded-night.reverse-any-amount.test.ts) 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 nonce — and shows the only remaining failure is `Wallet.InsufficientFunds`. + +The browser coin store is **not** the set of reversible coins. It records what this browser minted (so a forward conversion's exact coin is never fabricated) and carries the older v1 two-step UI's records, which still resume through it; valid legacy records migrate into the scoped v1 store. An uncertain forward submission keeps its minted record quarantined with the transaction id and network, and a known pre-submission failure or wallet cancellation removes a pending forward candidate. An uncertain reverse submission is reported with its transaction id and must not be retried blindly — a retry converts more sNight. ## Validation diff --git a/frontend/protocols/shared/adapter-core.ts b/frontend/protocols/shared/adapter-core.ts index 32eb556..ddbdb82 100644 --- a/frontend/protocols/shared/adapter-core.ts +++ b/frontend/protocols/shared/adapter-core.ts @@ -313,13 +313,23 @@ export async function createProtocolSession(input: { } callbacks.onStep?.('started', 'Converting sNight → NIGHT in one transaction…'); callbacks.onLog?.('convertToUnshielded — approve in wallet'); - const coin = coinStore.available().find((candidate) => candidate.value === amount - && bytesToHex(candidate.color) === normalizeTokenId(wrapperColorHex)); - if (!coin) { - throw new Error('Reverse conversion requires one exact sNight coin minted and retained by this browser.'); + // `convertToUnshielded` claims its coin as an output addressed to the + // contract; the wallet funds that output from the sNight it holds, with + // ordinary coin selection (inputs of that token type + change). The nonce + // is ours to choose and need not match an owned coin, so any amount up to + // the wallet's balance converts — including coins this browser never saw. + // Proven on chain in test/integration/shielded-night.reverse-any-amount.test.ts. + const walletTotal = pickBalance(await connectedAPI.getShieldedBalances(), [wrapperColorHex])?.value ?? 0n; + if (amount > walletTotal) { + throw new Error(`The wallet holds ${walletTotal} sNight; enter an amount up to that total.`); } - coinStore.stage(coin); - let callCompleted = false; + const coin: ShieldedCoinInfo = { + nonce: randomBytes32(), + color: hexToBytes(wrapperColorHex), + value: amount, + }; + // No coin-store bookkeeping on this path: the store records what this + // browser minted, not what can be reversed. try { await callCircuit('convertToUnshielded', [ coin, @@ -329,13 +339,17 @@ export async function createProtocolSession(input: { right: { bytes: bridge.addressToBytes(unshielded.unshieldedAddress) }, }, ]); - callCompleted = true; - coinStore.remove(coin); } catch (error) { - if (callCompleted) throw error; const identity = submissionIdentity(error); - if (identity) coinStore.markUncertain(coin, identity.transactionId, identity.networkId ?? networkId); - else coinStore.add(coin); + if (identity) { + throw Object.assign( + new Error( + `Transaction ${identity.transactionId} was submitted on ${identity.networkId ?? networkId}, but finalization could not be confirmed. Check that transaction before converting again — a blind retry converts more sNight.`, + { cause: error }, + ), + { transactionId: identity.transactionId, networkId: identity.networkId ?? networkId }, + ); + } throw error; } callbacks.onStep?.('done', 'Converted in one transaction ✓'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b37df83..9d93467 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,11 +22,7 @@ export default function App() { )} {sn.connected && ( - void sn.refreshBalances()} - mintedTotal={sn.balances?.trackedWrapperCoins.reduce((total, coin) => total + coin.value, 0n) ?? 0n} - /> + void sn.refreshBalances()} /> )} diff --git a/frontend/src/components/BalancePanel.tsx b/frontend/src/components/BalancePanel.tsx index aad93f5..d800c71 100644 --- a/frontend/src/components/BalancePanel.tsx +++ b/frontend/src/components/BalancePanel.tsx @@ -2,15 +2,17 @@ import { useState } from 'react'; import type { Balances } from '../../protocols/shared/types'; import { formatAmount } from '../lib/tokens'; +// No mint-versus-balance warning here. The browser's coin store records only +// what this browser minted, and the wallet's sNight moves independently of it: +// a reverse conversion (any amount, funded by wallet coin selection), a +// transfer, or a mint from another browser all make "minted here" differ from +// the wallet total legitimately. The old warning fired on exactly those cases. export function BalancePanel({ balances, onRefresh, - mintedTotal, }: { balances?: Balances; onRefresh: () => void; - /** sNight this dApp has minted (tracked coins) - used to detect a real mismatch. */ - mintedTotal: bigint; }) { const [copied, setCopied] = useState(null); @@ -42,8 +44,6 @@ export function BalancePanel({ {label} ); - const anomaly = mintedTotal > 0n && (!balances || !balances.wrapperMatched || balances.wrapper === 0n); - return (
@@ -60,12 +60,6 @@ export function BalancePanel({ ↻
- {anomaly && ( -

- This dApp minted {formatAmount(mintedTotal)} sNight but the wallet doesn't show it under the expected token - type (derived from the contract address). Check the network's address in .env matches the deployed contract. -

- )}
); } diff --git a/frontend/src/components/SwapCard.tsx b/frontend/src/components/SwapCard.tsx index ebf1994..118fe44 100644 --- a/frontend/src/components/SwapCard.tsx +++ b/frontend/src/components/SwapCard.tsx @@ -18,12 +18,15 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) { const [localErr, setLocalErr] = useState(); const { from, to } = TOKENS[direction]; - const trackedCoins = sn.balances?.trackedWrapperCoins ?? []; const blockedCoins = sn.balances?.blockedWrapperCoins ?? []; + // Reverse conversion spends the wallet's sNight balance: the wallet funds the + // contract-owned output with its own coin selection, so any amount up to the + // total works regardless of which browser or wallet minted those coins. + const walletWrapper = sn.balances?.wrapper ?? 0n; const ready = sn.connected && !!sn.contractAddress && !sn.configurationError - && (direction === 'toShielded' || trackedCoins.length > 0); + && (direction === 'toShielded' || walletWrapper > 0n); const flip = () => { setDirection((d) => (d === 'toShielded' ? 'toUnshielded' : 'toShielded')); @@ -34,7 +37,7 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) { // for reverse (the wallet funds the conversion + change during balancing). const maxBase = direction === 'toShielded' ? (sn.balances?.nativeNight ?? 0n) - : trackedCoins.reduce((largest, coin) => coin.value > largest ? coin.value : largest, 0n); + : walletWrapper; const onMax = () => { setLocalErr(undefined); setAmount(formatAmount(maxBase)); @@ -120,13 +123,11 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) { {direction === 'toUnshielded' && ( <>

- Reverse conversion spends one exact coin retained by this browser. Wallet total:{' '} - {formatAmount(sn.balances?.wrapper ?? 0n)} sNight. Tracked coin amounts:{' '} - {trackedCoins.length ? trackedCoins.map((coin) => formatAmount(coin.value)).join(', ') : 'none'}. + Wallet total: {formatAmount(walletWrapper)} sNight.

{blockedCoins.length > 0 && (

- {blockedCoins.map((coin) => `${formatAmount(coin.value)} sNight ${coin.status}${coin.transactionId ? ` (tx ${coin.transactionId}, ${coin.networkId ?? sn.networkKey})` : ' (no transaction id recorded)'}`).join('; ')}. Do not retry these coins until the transaction is checked. + {blockedCoins.map((coin) => `${formatAmount(coin.value)} sNight ${coin.status}${coin.transactionId ? ` (tx ${coin.transactionId}, ${coin.networkId ?? sn.networkKey})` : ' (no transaction id recorded)'}`).join('; ')}. This browser minted those coins without a confirmed outcome; check their transactions. They do not restrict what you can convert back.

)} @@ -148,10 +149,8 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) {

{sn.configurationError ? sn.configurationError - : direction === 'toUnshielded' && sn.connected && trackedCoins.length === 0 && blockedCoins.length > 0 - ? 'A retained sNight coin has a pending or uncertain outcome. Check its transaction status before retrying.' - : direction === 'toUnshielded' && sn.connected && trackedCoins.length === 0 - ? 'No exact sNight coin is retained by this browser; received or previously untracked coins cannot be reversed with the current wallet API.' + : direction === 'toUnshielded' && sn.connected && walletWrapper === 0n + ? 'No sNight in the wallet.' : sn.connected ? `Reconnect the wallet to ${sn.networkKey}.` : 'Connect a wallet to swap.'} diff --git a/test/integration/shielded-night.security.test.ts b/test/integration/shielded-night.security.test.ts index 6958c05..39b930b 100644 --- a/test/integration/shielded-night.security.test.ts +++ b/test/integration/shielded-night.security.test.ts @@ -56,9 +56,12 @@ describe('shielded-night — security', () => { const colorHex = tokenColorHex(color); const me = await getCoinPublicKey(c.walletCtx); - // --- Vector 1: a coin that was never minted. The circuit's - // receiveShielded claims it, but no wallet can supply the UTXO, so the - // transaction cannot balance. + // --- Vector 1: a coin that was never minted. receiveShielded claims it + // as an output addressed to the contract, but the wallet holds no sNight + // to fund that output (nothing minted yet), so the transaction cannot + // balance. What blocks the attack is the missing balance, not the nonce: + // see shielded-night.reverse-any-amount.test.ts, where a fresh nonce + // funded by a real balance succeeds. const thief = randomBytes32(); await attemptFails(() => contract.depositShielded(deployed, thief, { nonce: randomBytes32(), color, value: N }), @@ -75,8 +78,9 @@ describe('shielded-night — security', () => { ).private.result; await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b >= N); - // --- Vector 2: the real coin's nonce with an inflated value. The - // commitment doesn't match any owned UTXO, so it cannot balance. + // --- Vector 2: the real coin's nonce with an inflated value. The wallet + // holds N of the wrapper, not 2N, so it cannot fund an output of 2N and + // the transaction cannot balance. Inflation buys nothing. await attemptFails(() => contract.depositShielded(deployed, thief, { ...coin, value: N * 2n }), ); @@ -85,7 +89,9 @@ describe('shielded-night — security', () => { await contract.depositShielded(deployed, owner, coin); expect((await contract.getBalance(deployed, owner)).private.result).toBe(N); await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b === 0n); - // ...and the second attempt with the same (now spent) coin fails. + // ...and the second attempt fails: the burn consumed the wrapper, so the + // balance is 0 and there is nothing left to fund the output with (and + // re-creating the same commitment would be rejected in any case). await attemptFails(() => contract.depositShielded(deployed, owner, coin)); expect((await contract.getBalance(deployed, owner)).private.result).toBe(N); @@ -176,8 +182,10 @@ describe('shielded-night — security', () => { const myAddr = contract.rightUserAddress(getUserAddress(c.walletCtx).bytes); const night0 = await getNightBalance(c.walletCtx); - // --- Vector 1: a coin that was never minted. receiveShielded claims - // it, but no wallet can supply the UTXO, so the tx cannot balance. + // --- Vector 1: a coin that was never minted. receiveShielded claims it + // as a contract-owned output, but the wallet holds no sNight yet to fund + // that output, so the tx cannot balance. (With a real balance the same + // fresh-nonce shape succeeds — shielded-night.reverse-any-amount.test.ts.) await attemptFails(() => contract.convertToUnshielded( deployed, @@ -191,8 +199,8 @@ describe('shielded-night — security', () => { const coin = (await contract.convertToShielded(deployed, N, me, nonce)).private.result; await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b >= N); - // --- Vector 2: the real coin's nonce with an inflated value. The - // commitment doesn't match any owned UTXO, so it cannot balance. + // --- Vector 2: the real coin's nonce with an inflated value. The wallet + // holds N of the wrapper, not 2N, so it cannot fund an output of 2N. await attemptFails(() => contract.convertToUnshielded(deployed, { ...coin, value: 2n * N }, myAddr), ); @@ -201,7 +209,8 @@ describe('shielded-night — security', () => { await contract.convertToUnshielded(deployed, coin, myAddr); await waitForShieldedBalance(c.walletCtx.wallet, colorHex, (b) => b === 0n); expect(await waitForUnshieldedBalance(c.walletCtx.wallet, NIGHT_HEX, (b) => b >= night0)).toBe(night0); - // ...and a second release against the same (now spent) coin fails. + // ...and a second release fails: the wrapper balance is now 0, so there + // is nothing left to fund the output with. await attemptFails(() => contract.convertToUnshielded(deployed, coin, myAddr)); // --- Vector 4: replay-mint. Re-minting with the SAME nonce reproduces @@ -255,8 +264,9 @@ describe.skipIf((process.env.MN_ENV ?? 'undeployed') !== 'undeployed')( ).private.result; await waitForShieldedBalance(alice.walletCtx.wallet, colorHex, (b) => b >= N); - // Bob knows the coin's public info but does not own the UTXO: his - // wallet cannot supply it, so the deposit cannot balance. + // Bob knows the coin's public info, but he holds no sNight of his own: + // his wallet cannot fund the output the circuit claims, so the deposit + // cannot balance. Alice's balance is hers, not his. await attemptFails(() => contract.depositShielded(bobView, secretB, aliceCoin)); const state = await contract.factory.readLedger(bob.providers, address); expect(state?.balances.member(new Uint8Array(32))).toBe(false); diff --git a/test/unit/frontend-wallet-boundary.unit.test.ts b/test/unit/frontend-wallet-boundary.unit.test.ts index 53c544d..1e8d03b 100644 --- a/test/unit/frontend-wallet-boundary.unit.test.ts +++ b/test/unit/frontend-wallet-boundary.unit.test.ts @@ -11,6 +11,9 @@ const shieldedAddress = { shieldedEncryptionPublicKey: 'encryption-key', }; +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const transaction = (bytes: number[], id = 'balanced-transaction') => ({ identifiers: () => [id], serialize: () => Uint8Array.from(bytes), @@ -222,12 +225,11 @@ describe('frontend wallet transaction boundary', () => { expect(error.message).toContain('stagenet-tx submission on stagenet could not be confirmed'); }); - it('stores the exact forward result and reuses it for reverse instead of fabricating a coin', async () => { + it('stores the exact forward result the contract returned instead of a fabricated coin', async () => { const storage = new MemoryStorage(); const wrapperColor = 'ab'.repeat(32); let mintedCoin: ShieldedCoinInfo | undefined; let stagedBeforeWalletCall = false; - let reverseStagedBeforeWalletCall = false; const call = vi.fn(async (circuitId: string, args: unknown[]) => { if (circuitId === 'convertToShielded') { stagedBeforeWalletCall = [...storage.values.values()].some((raw) => raw.includes('"status":"pending"')); @@ -236,8 +238,6 @@ describe('frontend wallet transaction boundary', () => { color: Uint8Array.from({ length: 32 }, () => 0xab), value: 7n, }; - } else if (circuitId === 'convertToUnshielded') { - reverseStagedBeforeWalletCall = [...storage.values.values()].some((raw) => raw.includes('"status":"pending"')); } return { private: { result: circuitId === 'convertToShielded' ? mintedCoin : undefined } }; }); @@ -273,16 +273,9 @@ describe('frontend wallet transaction boundary', () => { coinStorage: storage, }); - await expect(session.convert('toUnshielded', 7n)).rejects.toThrow('requires one exact sNight coin'); - expect(call).not.toHaveBeenCalled(); await session.convert('toShielded', 7n); expect(stagedBeforeWalletCall).toBe(true); expect(session.trackedWrapperCoins()).toEqual([mintedCoin!]); - await session.convert('toUnshielded', 7n); - expect(reverseStagedBeforeWalletCall).toBe(true); - const reverseArguments = call.mock.calls[1][1] as unknown[]; - expect(reverseArguments[0]).toEqual(mintedCoin!); - expect(session.trackedWrapperCoins()).toEqual([]); }); it('migrates valid legacy v1 coin records into the scoped store', () => { @@ -384,20 +377,52 @@ describe('frontend wallet transaction boundary', () => { expect([...storage.values.values()].join('\n')).not.toContain('"status":"uncertain"'); }); - it('quarantines a real reverse coin when nested submission outcome is uncertain', async () => { + // Reverse conversion claims a contract-owned output that the wallet funds by + // ordinary coin selection over its sNight balance, so the browser's coin store + // is irrelevant to it. Proven on chain in + // test/integration/shielded-night.reverse-any-amount.test.ts. + it('reverses any amount the wallet holds with a fresh nonce and never reads the coin store', async () => { + const storage = new MemoryStorage(); + const call = vi.fn(async (_circuitId: string, _args: unknown[]) => ({ private: { result: undefined } })); + const { session, wrapperColor } = await makeProtocolSession(storage, call); + expect(session.trackedWrapperCoins()).toEqual([]); // nothing this browser minted + + await session.convert('toUnshielded', 5n); // wallet total is 7n + await session.convert('toUnshielded', 5n); + + expect(call.mock.calls.map(([circuitId]) => circuitId)).toEqual(['convertToUnshielded', 'convertToUnshielded']); + const coins = call.mock.calls.map(([, args]) => args[0] as ShieldedCoinInfo); + for (const coin of coins) { + expect(coin.nonce).toBeInstanceOf(Uint8Array); + expect(coin.nonce.length).toBe(32); + expect(bytesToHex(coin.color)).toBe(wrapperColor); + expect(coin.value).toBe(5n); + } + // A fresh nonce per call: reusing one would reproduce a coin commitment. + expect(bytesToHex(coins[0].nonce)).not.toBe(bytesToHex(coins[1].nonce)); + expect(call.mock.calls[0][1][1]).toEqual({ + is_left: false, + left: { bytes: new Uint8Array(32) }, + right: { bytes: new Uint8Array(32) }, + }); + // The store is untouched by the reverse path. + expect(session.wrapperCoinState()).toEqual({ available: [], blocked: [] }); + expect([...storage.values.values()].join('\n')).not.toContain('"status":"pending"'); + }); + + it('reports the wallet total and calls no circuit when the amount exceeds the balance', async () => { + const storage = new MemoryStorage(); + const call = vi.fn(async (_circuitId: string, _args: unknown[]) => ({ private: { result: undefined } })); + const { session } = await makeProtocolSession(storage, call); + + await expect(session.convert('toUnshielded', 8n)).rejects.toThrow( + 'The wallet holds 7 sNight; enter an amount up to that total.', + ); + expect(call).not.toHaveBeenCalled(); + }); + + it('surfaces the transaction id and warns against a blind retry when a reverse is uncertain', async () => { const storage = new MemoryStorage(); - const wrapperColor = 'ab'.repeat(32); - const coin = { - nonce: Uint8Array.from({ length: 32 }, () => 6), - color: Uint8Array.from({ length: 32 }, () => 0xab), - value: 7n, - }; - createWrapperCoinStore({ - protocolFamily: 'midnight-2.x', - networkId: 'stagenet', - contractAddress: 'cd'.repeat(32), - storage, - }).add(coin); const identity = Object.assign(new Error('connector response lost'), { transactionId: 'nested-reverse-tx', networkId: 'stagenet', @@ -405,12 +430,15 @@ describe('frontend wallet transaction boundary', () => { const wrapped = new Error('Midnight.js call failed', { cause: identity }); const { session } = await makeProtocolSession(storage, async () => { throw wrapped; }); - await expect(session.convert('toUnshielded', 7n)).rejects.toBe(wrapped); - expect(session.trackedWrapperCoins()).toEqual([]); - expect([...storage.values.values()].join('\n')).toContain('"transactionId":"nested-reverse-tx"'); + const error = await session.convert('toUnshielded', 7n).catch((caught) => caught); + expect(error).toMatchObject({ transactionId: 'nested-reverse-tx', networkId: 'stagenet' }); + expect(error.message).toContain('nested-reverse-tx'); + expect(error.message).toContain('a blind retry converts more sNight'); + expect(error.cause).toBe(wrapped); + expect(session.wrapperCoinState()).toEqual({ available: [], blocked: [] }); }); - it('restores a staged reverse coin after a known pre-submission cancellation', async () => { + it('leaves this browser’s minted coins untouched when the wallet cancels a reverse', async () => { const storage = new MemoryStorage(); const coin = { nonce: Uint8Array.from({ length: 32 }, () => 7), @@ -428,6 +456,7 @@ describe('frontend wallet transaction boundary', () => { await expect(session.convert('toUnshielded', 7n)).rejects.toBe(cancellation); expect(session.trackedWrapperCoins()).toEqual([coin]); + expect(session.wrapperCoinState().blocked).toEqual([]); }); it('stages and retains the exact legacy withdrawal coin before completing resume', async () => { From 76156a4cdac08b10418bc4e4826a0d9634d2f6c4 Mon Sep 17 00:00:00 2001 From: "Edward A." Date: Tue, 8 Sep 2026 22:00:07 -0300 Subject: [PATCH 3/3] Check the reverse amount before logging a wallet approval 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. --- frontend/protocols/shared/adapter-core.ts | 18 ++++++++++--- frontend/src/components/SwapCard.tsx | 10 +++++++ .../frontend-wallet-boundary.unit.test.ts | 26 ++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/frontend/protocols/shared/adapter-core.ts b/frontend/protocols/shared/adapter-core.ts index ddbdb82..9db5768 100644 --- a/frontend/protocols/shared/adapter-core.ts +++ b/frontend/protocols/shared/adapter-core.ts @@ -311,18 +311,30 @@ export async function createProtocolSession(input: { callbacks.onLog?.(`Minted ${amount} sNight (single tx)`); return; } - callbacks.onStep?.('started', 'Converting sNight → NIGHT in one transaction…'); - callbacks.onLog?.('convertToUnshielded — approve in wallet'); // `convertToUnshielded` claims its coin as an output addressed to the // contract; the wallet funds that output from the sNight it holds, with // ordinary coin selection (inputs of that token type + change). The nonce // is ours to choose and need not match an owned coin, so any amount up to // the wallet's balance converts — including coins this browser never saw. // Proven on chain in test/integration/shielded-night.reverse-any-amount.test.ts. + // + // The check runs before any step/log callback: a locally rejected amount + // must not leave "approve in wallet" in the activity log. const walletTotal = pickBalance(await connectedAPI.getShieldedBalances(), [wrapperColorHex])?.value ?? 0n; if (amount > walletTotal) { - throw new Error(`The wallet holds ${walletTotal} sNight; enter an amount up to that total.`); + // Backstop for a stale UI balance — the swap card checks first and + // formats for display. This layer has no decimals context, so it says + // "base units" rather than printing a raw number as if it were sNight, + // and attaches both values for a caller that wants to format them. + throw Object.assign( + new Error( + `The requested amount (${amount} base units) exceeds the wallet's sNight balance (${walletTotal} base units).`, + ), + { walletTotal, requested: amount }, + ); } + callbacks.onStep?.('started', 'Converting sNight → NIGHT in one transaction…'); + callbacks.onLog?.('convertToUnshielded — approve in wallet'); const coin: ShieldedCoinInfo = { nonce: randomBytes32(), color: hexToBytes(wrapperColorHex), diff --git a/frontend/src/components/SwapCard.tsx b/frontend/src/components/SwapCard.tsx index 118fe44..95c3238 100644 --- a/frontend/src/components/SwapCard.tsx +++ b/frontend/src/components/SwapCard.tsx @@ -55,6 +55,16 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) { return; } + // Reverse conversion is bounded by the wallet's sNight balance. Check it + // here so the message can be formatted in sNight (the adapter's own check + // is the authoritative backstop, but it only knows base units and would + // otherwise log "approve in wallet" for an amount we already know is too + // large). + if (direction === 'toUnshielded' && amt > walletWrapper) { + setLocalErr(`The wallet holds ${formatAmount(walletWrapper)} sNight; enter an amount up to that total.`); + return; + } + setBusy(true); setStep('started'); try { diff --git a/test/unit/frontend-wallet-boundary.unit.test.ts b/test/unit/frontend-wallet-boundary.unit.test.ts index 1e8d03b..1742875 100644 --- a/test/unit/frontend-wallet-boundary.unit.test.ts +++ b/test/unit/frontend-wallet-boundary.unit.test.ts @@ -410,14 +410,34 @@ describe('frontend wallet transaction boundary', () => { expect([...storage.values.values()].join('\n')).not.toContain('"status":"pending"'); }); - it('reports the wallet total and calls no circuit when the amount exceeds the balance', async () => { + it('states base units and calls no circuit when the amount exceeds the balance', async () => { const storage = new MemoryStorage(); const call = vi.fn(async (_circuitId: string, _args: unknown[]) => ({ private: { result: undefined } })); const { session } = await makeProtocolSession(storage, call); - await expect(session.convert('toUnshielded', 8n)).rejects.toThrow( - 'The wallet holds 7 sNight; enter an amount up to that total.', + // The adapter has no decimals context, so it must not print a raw base-unit + // number as if it were sNight - the swap card formats before calling. + const error = await session.convert('toUnshielded', 8n).catch((caught) => caught); + expect(error.message).toBe( + "The requested amount (8 base units) exceeds the wallet's sNight balance (7 base units).", ); + expect(error).toMatchObject({ walletTotal: 7n, requested: 8n }); + expect(call).not.toHaveBeenCalled(); + }); + + it('logs nothing about approving in the wallet when the pre-check rejects the amount', async () => { + const storage = new MemoryStorage(); + const call = vi.fn(async (_circuitId: string, _args: unknown[]) => ({ private: { result: undefined } })); + const { session } = await makeProtocolSession(storage, call); + const onStep = vi.fn(); + const onLog = vi.fn(); + + await expect(session.convert('toUnshielded', 8n, { onStep, onLog })).rejects.toThrow('base units'); + + // A locally rejected amount never reached the wallet, so the activity log + // must not claim it did. + expect(onLog).not.toHaveBeenCalled(); + expect(onStep).not.toHaveBeenCalled(); expect(call).not.toHaveBeenCalled(); });