Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
8 changes: 5 additions & 3 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 38 additions & 12 deletions frontend/protocols/shared/adapter-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,15 +311,37 @@ export async function createProtocolSession(input: {
callbacks.onLog?.(`Minted ${amount} sNight (single tx)`);
return;
}
// `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) {
// 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 = 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.');
}
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,
Expand All @@ -329,13 +351,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 ✓');
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ export default function App() {
)}

{sn.connected && (
<BalancePanel
balances={sn.balances}
onRefresh={() => void sn.refreshBalances()}
mintedTotal={sn.balances?.trackedWrapperCoins.reduce((total, coin) => total + coin.value, 0n) ?? 0n}
/>
<BalancePanel balances={sn.balances} onRefresh={() => void sn.refreshBalances()} />
)}

<SwapCard key={sn.networkKey} sn={sn} />
Expand Down
16 changes: 5 additions & 11 deletions frontend/src/components/BalancePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

Expand Down Expand Up @@ -42,8 +44,6 @@ export function BalancePanel({
<span className="bal-k">{label}</span>
);

const anomaly = mintedTotal > 0n && (!balances || !balances.wrapperMatched || balances.wrapper === 0n);

return (
<div className="balances">
<div className="balances-row">
Expand All @@ -60,12 +60,6 @@ export function BalancePanel({
</button>
</div>
{anomaly && (
<p className="small warn" style={{ margin: '6px 0 0' }}>
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.
</p>
)}
</div>
);
}
31 changes: 20 additions & 11 deletions frontend/src/components/SwapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) {
const [localErr, setLocalErr] = useState<string>();

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'));
Expand All @@ -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));
Expand All @@ -52,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 {
Expand Down Expand Up @@ -120,13 +133,11 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) {
{direction === 'toUnshielded' && (
<>
<p className="small muted" style={{ marginBottom: 0 }}>
Reverse conversion spends one exact coin retained by this browser. Wallet total:{' '}
<b>{formatAmount(sn.balances?.wrapper ?? 0n)}</b> sNight. Tracked coin amounts:{' '}
<b>{trackedCoins.length ? trackedCoins.map((coin) => formatAmount(coin.value)).join(', ') : 'none'}</b>.
Wallet total: <b>{formatAmount(walletWrapper)}</b> sNight.
</p>
{blockedCoins.length > 0 && (
<p className="small warn" style={{ marginBottom: 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.
</p>
)}
</>
Expand All @@ -148,10 +159,8 @@ export function SwapCard({ sn }: { sn: ShieldedNightState }) {
<p className="small muted" style={{ marginBottom: 0 }}>
{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.'}
Expand Down
Loading
Loading