From 06e3ee9b03afad6def268958716ae863bd75a2b0 Mon Sep 17 00:00:00 2001 From: Mingye Chen Date: Wed, 9 Sep 2026 15:39:30 -0700 Subject: [PATCH] Read confirmed token balance after a testnet mint --- sdk/README.md | 15 ++ sdk/services/zkapiClient.js | 60 +++++- .../browser-wallet-deposit-state.test.mjs | 185 ++++++++++++++++++ 3 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 sdk/test/browser-wallet-deposit-state.test.mjs diff --git a/sdk/README.md b/sdk/README.md index bf04bb7..9007dd1 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -76,3 +76,18 @@ Run `npm run test:sdk` in this checkout. For an installed package, run `node --test node_modules/@openanonymity/zkapi-browser-sdk/sdk/test/*.test.mjs` from a host that provides esbuild as a development dependency. The tests use local fixtures and do not connect a wallet or broadcast transactions. + +## Confirmed test-token balances + +After a Sepolia faucet mint, an injected wallet can return a successful receipt +before its cached `latest` balance read advances. Deposit preparation therefore +reads the token balance at the receipt's explicit block, checks that the block +hash is still canonical before and after the read, and rechecks the selected +chain. Temporarily unavailable or lagging state is retried for a bounded period; +only state reads are retried, never the mint transaction. A reorganization or +network change stops preparation for an explicit wallet status check. + +This does not alter mainnet token funding, deposit/withdrawal proof validation, +allowance handling, or the durable transaction recovery journal. The regression +suite exercises the real deposit path with stale provider reads and asserts +that only one mint and one vault deposit are submitted. diff --git a/sdk/services/zkapiClient.js b/sdk/services/zkapiClient.js index 3b0e479..31f0f28 100644 --- a/sdk/services/zkapiClient.js +++ b/sdk/services/zkapiClient.js @@ -933,14 +933,58 @@ class ZkapiClient extends EventTarget { return this.walletAddress; } - async readContractUint(to, data) { + async readContractUint(to, data, blockTag = 'latest') { const value = await globalThis.ethereum.request({ method: 'eth_call', - params: [{ to, data }, 'latest'] + params: [{ to, data }, blockTag] }); return BigInt(value || '0x0'); } + async readContractUintAtReceipt(to, data, receipt, minimum = 0n) { + const blockTag = receipt?.blockNumber; + const blockHash = receipt?.blockHash; + if (receipt?.status !== '0x1' || !/^0x[0-9a-f]+$/i.test(blockTag || '') + || !/^0x[0-9a-f]{64}$/i.test(blockHash || '')) { + throw new Error('The token transaction did not return a valid confirmed block. Check its status in MetaMask.'); + } + // An injected provider can return a mined receipt before its cached + // `latest` eth_call view advances. Read the receipt's explicit block + // instead, and retry only reads while the RPC nodes catch up. Never + // mint again merely because a post-transaction balance read is stale. + const canonicalBlock = async () => { + const block = await globalThis.ethereum.request({ + method: 'eth_getBlockByNumber', params: [blockTag, false] + }); + if (!block) throw new Error('The confirmed block is not available from MetaMask yet.'); + if (block.hash?.toLowerCase() !== blockHash.toLowerCase()) { + const error = new Error('The token transaction’s block changed. Check its status in MetaMask before trying again.'); + error.code = 'wallet_receipt_reorg'; + throw error; + } + }; + let lastError; + for (let attempt = 0; attempt < 20; attempt += 1) { + await this.assertFundingChain(); + try { + await canonicalBlock(); + const value = await this.readContractUint(to, data, blockTag); + await canonicalBlock(); + await this.assertFundingChain(); + if (value >= minimum) return value; + } catch (error) { + if (error?.code === 'wrong_network' || error?.code === 'wallet_receipt_reorg' + || isWalletRejection(error) || isDefinitelyPreBroadcastSendFailure(error)) throw error; + lastError = error; + } + if (attempt < 19) await new Promise(resolve => setTimeout(resolve, 500)); + } + const error = new Error('The token transaction was mined, but the required balance could not be confirmed. Check your wallet balance and try again.'); + error.code = 'wallet_state_pending'; + error.cause = lastError; + throw error; + } + async loadChallengePeriod() { const vault = this.config?.funding?.contract_address; if (!vault || !globalThis.ethereum) return; @@ -1547,18 +1591,18 @@ class ZkapiClient extends EventTarget { throw new Error(`Your wallet has ${formatTokenAmount(tokenBalance)} ${this.billingTokenSymbol}; this deposit needs ${formatTokenAmount(amount)} ${this.billingTokenSymbol}.`); } onStatus('Minting free test billing tokens… confirm in MetaMask.'); - await this.sendContractTransaction( + const mintReceipt = await this.sendContractTransaction( address, tokenAddress, callData(ABI.mint, [addressWord(address), abiWord(amount - tokenBalance)]) ); - tokenBalance = await this.readContractUint( + onStatus('Test tokens minted. Checking the confirmed balance…'); + tokenBalance = await this.readContractUintAtReceipt( tokenAddress, - callData(ABI.balanceOf, [addressWord(address)]) + callData(ABI.balanceOf, [addressWord(address)]), + mintReceipt, + amount ); - if (tokenBalance < amount) { - throw new Error('The test-token mint completed, but the balance is still too low.'); - } } onStatus('Generating the private note commitment locally…'); diff --git a/sdk/test/browser-wallet-deposit-state.test.mjs b/sdk/test/browser-wallet-deposit-state.test.mjs new file mode 100644 index 0000000..d6e660f --- /dev/null +++ b/sdk/test/browser-wallet-deposit-state.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const values = new Map(); +globalThis.localStorage = { + getItem: key => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: key => values.delete(key) +}; +globalThis.sessionStorage = globalThis.localStorage; +globalThis.window = new EventTarget(); +globalThis.window.location = { search: '', hostname: 'localhost' }; +const { ZkapiClient } = await import('../services/zkapiClient.js'); +const { default: runtime } = await import('../services/browserWalletRuntime.js'); +const { default: wallet } = await import('../wallet.js'); +const { ABI, callData, addressWord } = wallet; + +const ACCOUNT = `0x${'11'.repeat(20)}`; +const TOKEN = `0x${'22'.repeat(20)}`; +const VAULT = `0x${'33'.repeat(20)}`; +const BLOCK_HASH = `0x${'44'.repeat(32)}`; +const MINT_HASH = `0x${'55'.repeat(32)}`; +const DEPOSIT_HASH = `0x${'66'.repeat(32)}`; +const BLOCK = '0x123'; +const RECEIPT = { status: '0x1', blockNumber: BLOCK, blockHash: BLOCK_HASH, transactionHash: MINT_HASH }; +const BALANCE_CALL = callData(ABI.balanceOf, [addressWord(ACCOUNT)]); + +function client() { + const instance = new ZkapiClient(); + instance.config = { funding: { + chain_id: 11155111, contract_address: VAULT, + demo_billing_token_address: TOKEN, demo_mint_enabled: true + } }; + return instance; +} + +function instantSleeps(t) { + t.mock.method(globalThis, 'setTimeout', callback => { queueMicrotask(callback); return 1; }); +} + +test('a mined mint continues to deposit despite stale latest balance and lagging receipt-block reads, without reminting', async t => { + instantSleeps(t); + const instance = client(); + instance.browserMode = true; + const plan = { phase: 'prepared', next_note_id: 55, commitment: '0x88', zero_path: Array(32).fill('0x0') }; + t.mock.method(instance, 'connectWallet', async () => ACCOUNT); + t.mock.method(runtime, 'pendingDeposit', async () => null); + t.mock.method(runtime, 'prepareDeposit', async amount => { assert.equal(amount, 5_000_000); return plan; }); + t.mock.method(runtime, 'refreshPendingDeposit', async (amount, root) => { + assert.equal(amount, 5_000_000); + assert.equal(root, 0x77n); + return plan; + }); + t.mock.method(runtime, 'claimPendingDepositSubmission', async () => ({ operationId: 'test-deposit' })); + t.mock.method(runtime, 'rememberPendingDepositSubmissionMetadata', async () => {}); + t.mock.method(runtime, 'rememberPendingDepositTransaction', async () => {}); + t.mock.method(instance, 'confirmBrowserDepositReceipt', async (_plan, receipt) => { + assert.equal(receipt.transactionHash, DEPOSIT_HASH); + return { status: 'confirmed' }; + }); + let pinnedBalanceReads = 0; + let latestBalanceReads = 0; + const sends = []; + globalThis.ethereum = { request: async ({ method, params }) => { + if (method === 'eth_chainId') return '0xaa36a7'; + if (method === 'eth_getBlockByNumber') { + assert.deepEqual(params, [BLOCK, false]); + return { hash: BLOCK_HASH }; + } + if (method === 'eth_call') { + const [{ data }, block] = params; + if (data === BALANCE_CALL) { + if (block === 'latest') { latestBalanceReads += 1; return `0x${4_992_540n.toString(16)}`; } // 4.992540 + assert.equal(block, BLOCK); + pinnedBalanceReads += 1; + if (pinnedBalanceReads === 1) throw { code: -32000, message: 'header not found' }; + if (pinnedBalanceReads === 2) return `0x${4_992_540n.toString(16)}`; + return '0x4c4b40'; // 5.000000 + } + if (data.startsWith(`0x${ABI.allowance}`)) return '0x4c4b40'; + if (data === `0x${ABI.currentRoot}`) return '0x77'; + } + if (method === 'eth_estimateGas') return '0x50000'; + if (method === 'eth_getTransactionCount') return '0x1'; + if (method === 'eth_sendTransaction') { + sends.push(params[0]); + return sends.length === 1 ? MINT_HASH : DEPOSIT_HASH; + } + if (method === 'eth_getTransactionReceipt') return { ...RECEIPT, transactionHash: params[0] }; + throw new Error(`Unexpected RPC ${method}`); + } }; + assert.deepEqual(await instance.performDeposit('5'), { status: 'confirmed' }); + assert.equal(latestBalanceReads, 1); + assert.equal(pinnedBalanceReads, 3); + assert.equal(sends.length, 2, 'only mint and deposit may be submitted'); + assert.equal(sends[0].to, TOKEN); + assert.ok(sends[0].data.startsWith(`0x${ABI.mint}`)); + assert.equal(BigInt(`0x${sends[0].data.slice(-64)}`), 7460n); + assert.equal(sends[1].to, VAULT); + assert.ok(sends[1].data.startsWith(`0x${ABI.deposit}`)); +}); + +test('post-mint balance synchronization stops after bounded reads without issuing a transaction', async t => { + instantSleeps(t); + let reads = 0; + globalThis.ethereum = { request: async ({ method, params }) => { + if (method === 'eth_chainId') return '0xaa36a7'; + if (method === 'eth_getBlockByNumber') return { hash: BLOCK_HASH }; + assert.equal(method, 'eth_call'); + assert.equal(params[1], BLOCK); + reads += 1; + return '0x0'; + } }; + await assert.rejects(client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, RECEIPT, 5_000_000n), + error => error.code === 'wallet_state_pending'); + assert.equal(reads, 20); +}); + +test('a receipt-block hash mismatch fails before trusting its token balance', async () => { + globalThis.ethereum = { request: async ({ method }) => { + if (method === 'eth_chainId') return '0xaa36a7'; + assert.equal(method, 'eth_getBlockByNumber'); + return { hash: `0x${'77'.repeat(32)}` }; + } }; + await assert.rejects(client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, RECEIPT, 1n), + error => error.code === 'wallet_receipt_reorg'); +}); + +test('a temporarily unavailable receipt block is retried before reading state', async t => { + instantSleeps(t); + let blockReads = 0; + let stateReads = 0; + globalThis.ethereum = { request: async ({ method }) => { + if (method === 'eth_chainId') return '0xaa36a7'; + if (method === 'eth_getBlockByNumber') return ++blockReads === 1 ? null : { hash: BLOCK_HASH }; + assert.equal(method, 'eth_call'); + stateReads += 1; + return '0x4c4b40'; + } }; + assert.equal(await client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, RECEIPT, 1n), 5_000_000n); + assert.equal(blockReads, 3); + assert.equal(stateReads, 1); +}); + +test('a reorg between canonical check and state read rejects an otherwise sufficient balance', async () => { + let blockReads = 0; + globalThis.ethereum = { request: async ({ method }) => { + if (method === 'eth_chainId') return '0xaa36a7'; + if (method === 'eth_getBlockByNumber') return { hash: ++blockReads === 1 ? BLOCK_HASH : `0x${'77'.repeat(32)}` }; + assert.equal(method, 'eth_call'); + return '0x4c4b40'; + } }; + await assert.rejects(client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, RECEIPT, 1n), + error => error.code === 'wallet_receipt_reorg'); + assert.equal(blockReads, 2); +}); + +test('a different network fails before any receipt-block or token-state reads', async () => { + globalThis.ethereum = { request: async ({ method }) => { + assert.equal(method, 'eth_chainId'); + return '0x1'; + } }; + await assert.rejects(client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, RECEIPT, 1n), + error => error.code === 'wrong_network'); +}); + +test('switching networks during the read never accepts the other chain’s balance', async () => { + let switched = false; + globalThis.ethereum = { request: async ({ method }) => { + if (method === 'eth_chainId') return switched ? '0x1' : '0xaa36a7'; + if (method === 'eth_getBlockByNumber') return { hash: BLOCK_HASH }; + assert.equal(method, 'eth_call'); + switched = true; + return '0x4c4b40'; + } }; + await assert.rejects(client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, RECEIPT, 1n), + error => error.code === 'wrong_network'); +}); + +test('reverted or incomplete receipts never authorize a post-mint state read', async () => { + globalThis.ethereum = { request: async () => { throw new Error('No RPC expected'); } }; + for (const receipt of [{ ...RECEIPT, status: '0x0' }, { ...RECEIPT, blockHash: null }, { ...RECEIPT, blockNumber: 'latest' }]) { + await assert.rejects(client().readContractUintAtReceipt(TOKEN, BALANCE_CALL, receipt), /valid confirmed block/); + } +});