From 48d02dd79c73409a580ecd94957404ac4c1aa350 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 10 Nov 2026 11:59:06 -0500 Subject: [PATCH 1/3] fix: resolve funding status when Core omits confirmations --- .../fundingStatus.integration.test.ts | 70 +++++++++++++++++++ src/core/counterparty/api.ts | 12 ++-- src/core/counterparty/pendingAttachments.ts | 12 ++-- 3 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 src/core/counterparty/__tests__/fundingStatus.integration.test.ts diff --git a/src/core/counterparty/__tests__/fundingStatus.integration.test.ts b/src/core/counterparty/__tests__/fundingStatus.integration.test.ts new file mode 100644 index 000000000..b89f90636 --- /dev/null +++ b/src/core/counterparty/__tests__/fundingStatus.integration.test.ts @@ -0,0 +1,70 @@ +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { Transaction } from '@scure/btc-signer'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { apiClient } from '@/core/api/client'; +import { clearApiCache } from '@/core/counterparty/api'; +import { assertFundingIsAssetFree } from '@/core/counterparty/fundingAssetGuard'; + +vi.mock('@/core/api/client', () => ({ apiClient: { get: vi.fn() }, isApiError: () => false })); + +// Shape returned by Core's Bitcoin endpoint: decoded transaction bytes, no confirmations. +const parent = new Transaction({ allowUnknownOutputs: true, disableScriptCheck: true }); +parent.addInput({ txid: '11'.repeat(32), index: 0 }); +parent.addOutput({ script: hexToBytes(`0014${'22'.repeat(20)}`), amount: 54840n }); +const spending = new Transaction({ allowUnknownOutputs: true, disableScriptCheck: true }); +spending.addInput({ txid: parent.id, index: 0 }); +spending.addOutput({ script: hexToBytes(`0014${'22'.repeat(20)}`), amount: 54000n }); +const raw = bytesToHex(spending.toBytes(true, false)); +let chainStatus: unknown; +let ledgerHeight: number; +let balances: unknown[]; + +beforeEach(() => { + clearApiCache(); + vi.clearAllMocks(); + chainStatus = { confirmed: true, block_height: 100 }; + ledgerHeight = 100; + balances = []; + vi.mocked(apiClient.get).mockImplementation(async (url) => { + let data: unknown; + if (url.endsWith(`/bitcoin/transactions/${parent.id}`)) { + data = { result: { hex: bytesToHex(parent.toBytes(true, false)) } }; + } else if (url.endsWith('/withbalances')) { + data = { result: { [`${parent.id}:0`]: balances.length > 0 } }; + } else if (url.endsWith('/balances')) { + data = { result: balances }; + } else if (url.endsWith(`/tx/${parent.id}/status`)) { + data = chainStatus; + } else if (url.endsWith('/v2/')) { + data = { result: { backend_height: 100, counterparty_height: ledgerHeight } }; + } else { + throw new Error(`Unexpected request: ${url}`); + } + return { data, status: 200 } as never; + }); +}); + +it('allows confirmed indexed clean funding when Core omits confirmations', async () => { + await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).resolves.toBeUndefined(); + expect(vi.mocked(apiClient.get).mock.calls.some(([url]) => url.endsWith('/status'))).toBe(true); +}); + +it('does not treat missing explorer status as clean', async () => { + chainStatus = null; + await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).rejects.toThrow('asset-free'); +}); + +it('does not accept a confirmed output ahead of the ledger', async () => { + ledgerHeight = 99; + await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).rejects.toThrow('asset-free'); +}); + +it('still rejects actual attached assets', async () => { + balances = [{ asset: 'XCPETS', quantity_normalized: '1', quantity: '100000000' }]; + await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).rejects.toThrow('asset-free'); +}); + +it('still checks ancestors for an actually unconfirmed output', async () => { + chainStatus = { confirmed: false }; + await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).rejects.toThrow('asset-free'); +}); \ No newline at end of file diff --git a/src/core/counterparty/api.ts b/src/core/counterparty/api.ts index eb25dfe36..d4da6585a 100644 --- a/src/core/counterparty/api.ts +++ b/src/core/counterparty/api.ts @@ -876,13 +876,14 @@ export async function fetchUtxoBalances( /** * A Bitcoin transaction as the Counterparty node's own backend sees it, uncached. * - * `confirmations` is absent (or zero) while the transaction is unconfirmed. Callers deciding + * Zero confirmations means unconfirmed; an absent field means the backend omitted chain status. + * Callers deciding * whether the Counterparty ledger can already reflect this transaction must compare it with * `fetchLedgerHeights`, fetched afterwards. */ export async function fetchBackendTransaction( txid: string -): Promise<{ hex: string; confirmations: number }> { +): Promise<{ hex: string; confirmations?: number }> { const data = await cpApiGet<{ result?: { hex?: unknown; confirmations?: unknown } }>( `/v2/bitcoin/transactions/${encodePath(txid)}`, undefined, @@ -895,9 +896,10 @@ export async function fetchBackendTransaction( const confirmations = data.result?.confirmations; return { hex, - confirmations: typeof confirmations === 'number' && Number.isSafeInteger(confirmations) && confirmations > 0 - ? confirmations - : 0, + // Some Core backends return decoded bytes without chain status. Missing is not zero. + ...(typeof confirmations === 'number' && Number.isSafeInteger(confirmations) && confirmations >= 0 + ? { confirmations } + : {}), }; } diff --git a/src/core/counterparty/pendingAttachments.ts b/src/core/counterparty/pendingAttachments.ts index ef8449169..3699bf390 100644 --- a/src/core/counterparty/pendingAttachments.ts +++ b/src/core/counterparty/pendingAttachments.ts @@ -65,16 +65,20 @@ export interface AttachmentEvidenceSource { } async function liveParent(txid: string): Promise { + let backendHex: string | undefined; try { const tx = await fetchBackendTransaction(txid); - return tx.confirmations > 0 - ? { rawTxHex: tx.hex, confirmed: true, confirmations: tx.confirmations } - : { rawTxHex: tx.hex, confirmed: false }; + backendHex = tx.hex; + if (tx.confirmations !== undefined) { + return tx.confirmations > 0 + ? { rawTxHex: tx.hex, confirmed: true, confirmations: tx.confirmations } + : { rawTxHex: tx.hex, confirmed: false }; + } } catch { // The node's backend may not have seen a just-relayed transaction. Try the explorer. } const [rawTxHex, status] = await Promise.all([ - fetchPreviousRawTransaction(txid), + backendHex ?? fetchPreviousRawTransaction(txid), fetchTransactionChainStatus(txid), ]); if (!rawTxHex || !status) return null; From f935d72cfff27463695835647a68c6e86029498f Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 10 Nov 2026 12:22:40 -0500 Subject: [PATCH 2/3] fix: batch internal funding checks and prepare 0.16.1 --- package-lock.json | 4 +- package.json | 2 +- src/components/composer/composer.test.tsx | 8 +- .../composer-context.taproot.test.tsx | 8 +- .../__tests__/attachFunding.test.ts | 38 ++++++++- .../fixtures/core-missing-confirmations.json | 81 +++++++++++++++++++ .../fundingStatus.integration.test.ts | 26 +++++- src/core/counterparty/fundingAssetGuard.ts | 4 +- src/core/counterparty/inputAssets.ts | 15 ++++ src/core/zeld/__tests__/sendCompose.test.ts | 6 +- src/core/zeld/spendable.ts | 4 +- 11 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 src/core/counterparty/__tests__/fixtures/core-missing-confirmations.json diff --git a/package-lock.json b/package-lock.json index 9b4cfb16e..8a79a710e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xcp-wallet", - "version": "0.16.0", + "version": "0.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xcp-wallet", - "version": "0.16.0", + "version": "0.16.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index cbb866972..7c71d60e8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "xcp-wallet", "description": "Counterparty Web3 Wallet", "private": true, - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "type": "module", "engines": { diff --git a/src/components/composer/composer.test.tsx b/src/components/composer/composer.test.tsx index 6201c70eb..c96622e1f 100644 --- a/src/components/composer/composer.test.tsx +++ b/src/components/composer/composer.test.tsx @@ -8,7 +8,7 @@ import type { ReactElement } from 'react'; import { MemoryRouter } from 'react-router'; import { decodeAddressFromScript } from '@/core/bitcoin/address'; import { AddressFormat } from '@/core/bitcoin/addressFormat'; -import { fetchInputsAttachedAssets } from '@/core/counterparty/inputAssets'; +import { fetchWalletInputsAttachedAssets } from '@/core/counterparty/inputAssets'; import { encodeCbor } from '@/core/counterparty/pack/cbor'; import { packAddress } from '@/core/counterparty/unpack/address'; import { arc4 } from '@/core/counterparty/unpack/binary'; @@ -45,7 +45,7 @@ import { useComposer } from '@/contexts/composer-context-object'; import { Composer } from './composer'; // Mock dependencies -vi.mock('@/core/counterparty/inputAssets', () => ({ fetchInputsAttachedAssets: vi.fn() })); +vi.mock('@/core/counterparty/inputAssets', () => ({ fetchWalletInputsAttachedAssets: vi.fn() })); const mockNavigate = vi.fn(); vi.mock('react-router', async () => { const actual = await vi.importActual('react-router'); @@ -208,7 +208,7 @@ describe('Composer', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(fetchInputsAttachedAssets).mockResolvedValue([]); + vi.mocked(fetchWalletInputsAttachedAssets).mockResolvedValue([]); mockSignTransaction.mockResolvedValue('signed123'); mockBroadcastTransaction.mockResolvedValue({ tx_hash: 'broadcast123' }); mockComposeApi.mockResolvedValue(mockApiResponse); @@ -280,7 +280,7 @@ describe('Composer', () => { }); it('refuses to sign when funding evidence is unavailable', async () => { - vi.mocked(fetchInputsAttachedAssets).mockResolvedValue([{ inputIndex: 0, utxo: 'unknown:0', assets: [], lookupFailed: true }]); + vi.mocked(fetchWalletInputsAttachedAssets).mockResolvedValue([{ inputIndex: 0, utxo: 'unknown:0', assets: [], lookupFailed: true }]); renderWithProvider(); fireEvent.submit(screen.getByTestId('form-component')); await waitFor(() => expect(screen.queryByTestId('review-component')).toBeInTheDocument()); diff --git a/src/contexts/__tests__/composer-context.taproot.test.tsx b/src/contexts/__tests__/composer-context.taproot.test.tsx index ffbcea1a7..beac7eba9 100644 --- a/src/contexts/__tests__/composer-context.taproot.test.tsx +++ b/src/contexts/__tests__/composer-context.taproot.test.tsx @@ -22,7 +22,7 @@ import { } from '@/core/counterparty/__tests__/taproot115Fixtures'; import { SEND_TAPROOT } from '@/core/counterparty/__tests__/taprootFixtures'; import type { ApiResponse } from '@/core/counterparty/compose'; -import { fetchInputsAttachedAssets } from '@/core/counterparty/inputAssets'; +import { fetchWalletInputsAttachedAssets } from '@/core/counterparty/inputAssets'; import { checkRevealSourceSignature, sourceOutputScript } from '@/core/counterparty/revealSourceRule'; import { CounterpartyApiError } from '@/core/errors'; import { ComposerProvider } from '../composer-context'; @@ -35,7 +35,7 @@ const signCommitAndReveal = vi.fn(); const broadcastTransaction = vi.fn(); // Network evidence is stubbed; the production pre-sign guard remains active. -vi.mock('@/core/counterparty/inputAssets', () => ({ fetchInputsAttachedAssets: vi.fn() })); +vi.mock('@/core/counterparty/inputAssets', () => ({ fetchWalletInputsAttachedAssets: vi.fn() })); vi.mock('@/contexts/wallet-context', () => ({ useWallet: () => ({ @@ -130,7 +130,7 @@ async function signBoth(rawTxHex: string, address: string, reveal: TaprootReveal describe('ComposerContext Taproot encoding (Core 11.5)', () => { beforeEach(() => { - vi.mocked(fetchInputsAttachedAssets).mockReset().mockResolvedValue([]); + vi.mocked(fetchWalletInputsAttachedAssets).mockReset().mockResolvedValue([]); zeldHuntSeconds = 0; walletType = 'mnemonic'; signTransaction.mockReset(); @@ -144,7 +144,7 @@ describe('ComposerContext Taproot encoding (Core 11.5)', () => { }); it('does not sign either transaction when commit funding has unknown asset status', async () => { - vi.mocked(fetchInputsAttachedAssets).mockResolvedValue([{ inputIndex: 0, utxo: 'unknown:0', assets: [], lookupFailed: true }]); + vi.mocked(fetchWalletInputsAttachedAssets).mockResolvedValue([{ inputIndex: 0, utxo: 'unknown:0', assets: [], lookupFailed: true }]); const { result } = await composed(vi.fn(async () => responseFor())); await act(async () => { await result.current.signAndBroadcast(); }); expect(result.current.state.error).toMatch(/asset-free/); diff --git a/src/core/counterparty/__tests__/attachFunding.test.ts b/src/core/counterparty/__tests__/attachFunding.test.ts index d0753b58c..626abf11e 100644 --- a/src/core/counterparty/__tests__/attachFunding.test.ts +++ b/src/core/counterparty/__tests__/attachFunding.test.ts @@ -2,6 +2,8 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; import { Script, Transaction } from '@scure/btc-signer'; import { describe, expect, it } from 'vitest'; import { assertFundingIsAssetFree } from '../fundingAssetGuard'; +import { MAX_ASSET_LOOKUP_INPUTS } from '../inputAssetLimits'; +import { fetchInputsAttachedAssets } from '../inputAssets'; import type { AttachmentEvidenceSource } from '../pendingAttachments'; import { arc4 } from '../unpack/binary'; @@ -65,4 +67,38 @@ it.each(['move', 'detach'])('allows only the explicitly selected %s source plus await expect(assertFundingIsAssetFree(spend([0, 2]), { composeType, sourceUtxo: `${parent.id}:2` }, evidence())).rejects.toThrow('asset-free'); await expect(assertFundingIsAssetFree(spend([0]), { composeType, sourceUtxo: `${parent.id}:2` }, evidence())).rejects.toThrow('does not spend'); await expect(assertFundingIsAssetFree(spend([0]), { composeType }, evidence())).rejects.toThrow('Select'); -}); \ No newline at end of file +}); + +describe('large internal transactions', () => { + const count = MAX_ASSET_LOOKUP_INPUTS + 1; + const funding = new Transaction({ allowUnknownOutputs: true, disableScriptCheck: true }); + funding.addInput({ txid: fundingTxid, index: 0 }); + for (let i = 0; i < count; i++) funding.addOutput({ script, amount: 10000n }); + const inputs = Array.from({ length: count }, (_, index) => ({ index, txid: funding.id, vout: index })); + const spending = new Transaction({ allowUnknownOutputs: true, disableScriptCheck: true }); + for (const input of inputs) spending.addInput({ txid: input.txid, index: input.vout }); + spending.addOutput({ script, amount: 10000n }); + const raw = bytesToHex(spending.toBytes(true, false)); + const source: AttachmentEvidenceSource = { + balances: async () => [], + parent: async () => ({ rawTxHex: bytesToHex(funding.toBytes(true, false)), confirmed: true, blockHeight: 100 }), + ledgerHeights: async () => ({ backendHeight: 100, counterpartyHeight: 100 }), + }; + + it('checks clean funding beyond the external approval cap', async () => { + await expect(assertFundingIsAssetFree(raw, { composeType: 'zeld' }, source)).resolves.toBeUndefined(); + }); + + it('still rejects an attached asset in the last batch', async () => { + const attached: AttachmentEvidenceSource = { ...source, balances: async outpoint => + outpoint === `${funding.id}:${count - 1}` + ? [{ asset: 'XCP', quantity: '1', quantity_normalized: '0.00000001' }] as never : [] }; + await expect(assertFundingIsAssetFree(raw, { composeType: 'zeld' }, attached)).rejects.toThrow('asset-free'); + }); + + it('retains the input cap for external approval lookups', async () => { + const result = await fetchInputsAttachedAssets(inputs, undefined, async () => null, source); + expect(result).toEqual([{ inputIndex: count - 1, utxo: `${funding.id}:${count - 1}`, + assets: [], lookupFailed: true, overLimit: true }]); + }); +}); diff --git a/src/core/counterparty/__tests__/fixtures/core-missing-confirmations.json b/src/core/counterparty/__tests__/fixtures/core-missing-confirmations.json new file mode 100644 index 000000000..9618589d1 --- /dev/null +++ b/src/core/counterparty/__tests__/fixtures/core-missing-confirmations.json @@ -0,0 +1,81 @@ +{ + "source": "https://api.counterparty.io:4000", + "txid": "291bdbbe4932cb9807e5a3f1c0c5cacaa3f335b77e20aa4891d901f453500de2", + "vout": 1, + "transaction": { + "result": { + "txid": "291bdbbe4932cb9807e5a3f1c0c5cacaa3f335b77e20aa4891d901f453500de2", + "hash": "291bdbbe4932cb9807e5a3f1c0c5cacaa3f335b77e20aa4891d901f453500de2", + "version": 2, + "size": 260, + "vsize": 260, + "weight": 1040, + "locktime": 0, + "vin": [ + { + "txid": "27477e6cdcceab88e8fac0faf7e7bacc6626e8a036c5b25a9a2103515dbf0ed8", + "vout": 1, + "scriptSig": { + "asm": "3044022050c86d44b855d1cb08c02c104fb5f3804f0fc8408f6e8aa368845b9e27a4915b0220560d53b26f55795dd7c4cd1067549e0607c147d61b201707911af27d34778171[ALL] 0258c08cade470d81a9fd83913d14a293121849b8f4a48b6b40062788102519ee7", + "hex": "473044022050c86d44b855d1cb08c02c104fb5f3804f0fc8408f6e8aa368845b9e27a4915b0220560d53b26f55795dd7c4cd1067549e0607c147d61b201707911af27d3477817101210258c08cade470d81a9fd83913d14a293121849b8f4a48b6b40062788102519ee7" + }, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 0.0, + "n": 0, + "scriptPubKey": { + "asm": "OP_RETURN 6946051296fce2e9007c5fc2fe46e9509b5f819b5bd7a104ac79789f428e0db31438455fffdd2c61c04e6c61afb750b8b6fd312b2502e21a36b5", + "desc": "raw(6a3a6946051296fce2e9007c5fc2fe46e9509b5f819b5bd7a104ac79789f428e0db31438455fffdd2c61c04e6c61afb750b8b6fd312b2502e21a36b5)#vuamg4fv", + "hex": "6a3a6946051296fce2e9007c5fc2fe46e9509b5f819b5bd7a104ac79789f428e0db31438455fffdd2c61c04e6c61afb750b8b6fd312b2502e21a36b5", + "type": "nulldata" + } + }, + { + "value": 0.0005484, + "n": 1, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 32dd6890907e5ea8b56150e741890af97702701c OP_EQUALVERIFY OP_CHECKSIG", + "desc": "addr(15dx2KbwKxppVjJYvXrK6pCwnQeqat71ZA)#efsf9v59", + "hex": "76a91432dd6890907e5ea8b56150e741890af97702701c88ac", + "address": "15dx2KbwKxppVjJYvXrK6pCwnQeqat71ZA", + "type": "pubkeyhash" + } + } + ], + "hex": "0200000001d80ebf5d5103219a5ab2c536a0e82666ccbae7f7fac0fae888abcedc6c7e4727010000006a473044022050c86d44b855d1cb08c02c104fb5f3804f0fc8408f6e8aa368845b9e27a4915b0220560d53b26f55795dd7c4cd1067549e0607c147d61b201707911af27d3477817101210258c08cade470d81a9fd83913d14a293121849b8f4a48b6b40062788102519ee7ffffffff0200000000000000003c6a3a6946051296fce2e9007c5fc2fe46e9509b5f819b5bd7a104ac79789f428e0db31438455fffdd2c61c04e6c61afb750b8b6fd312b2502e21a36b538d60000000000001976a91432dd6890907e5ea8b56150e741890af97702701c88ac00000000" + } + }, + "status": { + "confirmed": true, + "block_height": 975321, + "block_hash": "000000000000000000005382c94e4613748550e85928e59148b8f466382b34a7", + "block_time": 1794328019 + }, + "heights": { + "result": { + "server_ready": true, + "network": "mainnet", + "version": "11.5.0", + "backend_height": 975326, + "counterparty_height": 975326, + "ledger_state": "Following", + "documentation": "https://apidocs.counterparty.io/", + "routes": "http://api.counterparty.io:4000/v2/routes", + "openapi": "https://raw.githubusercontent.com/CounterpartyXCP/counterparty-core/refs/heads/master/openapi.json", + "current_commit": "8aab989157019e62423cd959b8571c7daf748d56" + } + }, + "balances": { + "result": [], + "next_cursor": null, + "result_count": 0 + }, + "membership": { + "result": { + "291bdbbe4932cb9807e5a3f1c0c5cacaa3f335b77e20aa4891d901f453500de2:1": false + } + } +} diff --git a/src/core/counterparty/__tests__/fundingStatus.integration.test.ts b/src/core/counterparty/__tests__/fundingStatus.integration.test.ts index b89f90636..57662ee3f 100644 --- a/src/core/counterparty/__tests__/fundingStatus.integration.test.ts +++ b/src/core/counterparty/__tests__/fundingStatus.integration.test.ts @@ -4,6 +4,7 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { apiClient } from '@/core/api/client'; import { clearApiCache } from '@/core/counterparty/api'; import { assertFundingIsAssetFree } from '@/core/counterparty/fundingAssetGuard'; +import captured from './fixtures/core-missing-confirmations.json'; vi.mock('@/core/api/client', () => ({ apiClient: { get: vi.fn() }, isApiError: () => false })); @@ -67,4 +68,27 @@ it('still rejects actual attached assets', async () => { it('still checks ancestors for an actually unconfirmed output', async () => { chainStatus = { confirmed: false }; await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).rejects.toThrow('asset-free'); -}); \ No newline at end of file +}); + +it('accepts the reported holder’s confirmed clean output using captured public API responses', async () => { + const tx = new Transaction({ allowUnknownOutputs: true, disableScriptCheck: true }); + tx.addInput({ txid: captured.txid, index: captured.vout }); + tx.addOutput({ script: hexToBytes(`0014${'22'.repeat(20)}`), amount: 54000n }); + vi.mocked(apiClient.get).mockImplementation(async (url) => { + const data = url.endsWith(`/bitcoin/transactions/${captured.txid}`) ? captured.transaction + : url.endsWith('/withbalances') ? captured.membership + : url.endsWith('/balances') ? captured.balances + : url.endsWith(`/tx/${captured.txid}/status`) ? captured.status + : url.endsWith('/v2/') ? captured.heights + : undefined; + if (!data) throw new Error(`Unexpected request: ${url}`); + return { data, status: 200 } as never; + }); + await expect(assertFundingIsAssetFree(bytesToHex(tx.toBytes(true, false)), { composeType: 'destroy' })) + .resolves.toBeUndefined(); +}); + +it('does not accept a confirmed status without a block height', async () => { + chainStatus = { confirmed: true }; + await expect(assertFundingIsAssetFree(raw, { composeType: 'destroy' })).rejects.toThrow('asset-free'); +}); diff --git a/src/core/counterparty/fundingAssetGuard.ts b/src/core/counterparty/fundingAssetGuard.ts index e37212dc5..69d99aa1d 100644 --- a/src/core/counterparty/fundingAssetGuard.ts +++ b/src/core/counterparty/fundingAssetGuard.ts @@ -1,5 +1,5 @@ import { parseRawTransactionLocally } from '@/core/bitcoin/localTransactionParse'; -import { fetchInputsAttachedAssets } from '@/core/counterparty/inputAssets'; +import { fetchWalletInputsAttachedAssets } from '@/core/counterparty/inputAssets'; import { type AttachmentEvidenceSource, liveAttachmentEvidenceSource } from '@/core/counterparty/pendingAttachments'; export interface FundingIntent { @@ -31,7 +31,7 @@ export async function assertFundingIsAssetFree( if (funding.length === 0) return; // Recheck actual inputs, including Core-selected fallback inputs. Do not treat a local // change-cache entry as proof: an implicit asset move can also pay the wallet itself. - const assets = await fetchInputsAttachedAssets(funding, undefined, async () => null, evidenceSource); + const assets = await fetchWalletInputsAttachedAssets(funding, evidenceSource); if (assets.some(entry => entry.assets.length > 0 || entry.lookupFailed)) { throw new Error('Transaction funding contains assets or cannot yet be proven asset-free. Wait for pending transactions to confirm and be indexed, or add clean BTC.'); } diff --git a/src/core/counterparty/inputAssets.ts b/src/core/counterparty/inputAssets.ts index 6d5838dc8..6f4a1d12a 100644 --- a/src/core/counterparty/inputAssets.ts +++ b/src/core/counterparty/inputAssets.ts @@ -139,6 +139,21 @@ export async function fetchInputsAttachedAssets( return [...results.filter((r): r is InputAttachedAssets => r !== null), ...displaced]; } +/** Internal wallet inventory/transactions may exceed an external approval's input cap. + * Verify every input in sequential bounded batches; website approvals retain the original cap. */ +export async function fetchWalletInputsAttachedAssets( + inputs: Array<{ index: number; txid: string; vout: number }>, + evidenceSource: AttachmentEvidenceSource = liveAttachmentEvidenceSource, +): Promise { + const results: InputAttachedAssets[] = []; + for (let offset = 0; offset < inputs.length; offset += MAX_ASSET_LOOKUP_INPUTS) { + results.push(...await fetchInputsAttachedAssets( + inputs.slice(offset, offset + MAX_ASSET_LOOKUP_INPUTS), undefined, noTrustedPrevout, evidenceSource, + )); + } + return results; +} + /** * Which of these outpoints the ledger says hold any balance, asked in batches rather than one * request per input. Null when the source cannot answer, so each input is read on its own. diff --git a/src/core/zeld/__tests__/sendCompose.test.ts b/src/core/zeld/__tests__/sendCompose.test.ts index 09d39de4e..a2363810d 100644 --- a/src/core/zeld/__tests__/sendCompose.test.ts +++ b/src/core/zeld/__tests__/sendCompose.test.ts @@ -5,7 +5,7 @@ import { parsePSBT } from '@/core/bitcoin/psbt'; import { clearSpentUtxoCache, recordSpentUtxos } from '@/core/bitcoin/spentUtxoCache'; import { fetchUTXOs } from '@/core/bitcoin/utxo'; import { fetchUtxosWithBalances } from '@/core/counterparty/api'; -import { fetchInputsAttachedAssets } from '@/core/counterparty/inputAssets'; +import { fetchWalletInputsAttachedAssets } from '@/core/counterparty/inputAssets'; import { checkOutputPolicy, pinnedDestinations, withPinnedDestinations } from '@/core/counterparty/outputPolicy'; import { packComposeMessage } from '@/core/counterparty/pack/messages'; import { selectUtxosForTransaction } from '@/core/counterparty/utxoSelection'; @@ -29,7 +29,7 @@ vi.mock('@/core/bitcoin/utxo', async (importOriginal) => ({ })); vi.mock('@/core/counterparty/api', () => ({ fetchUtxosWithBalances: vi.fn() })); vi.mock('@/core/counterparty/inputAssets', () => ({ - fetchInputsAttachedAssets: vi.fn(async (inputs: Array<{ index: number; txid: string; vout: number }>) => { + fetchWalletInputsAttachedAssets: vi.fn(async (inputs: Array<{ index: number; txid: string; vout: number }>) => { const attached = await fetchUtxosWithBalances(inputs.map(input => `${input.txid}:${input.vout}`)); return inputs.filter(input => attached.has(`${input.txid}:${input.vout}`)).map(input => ({ inputIndex: input.index, utxo: `${input.txid}:${input.vout}`, assets: [{ asset: 'XCP', quantity_normalized: '1', asset_longname: null }], @@ -68,7 +68,7 @@ describe('composeZeldSend', () => { }); it('does not offer pending or unknown Counterparty attachment inputs as ZELD', async () => { - vi.mocked(fetchInputsAttachedAssets).mockResolvedValueOnce([ + vi.mocked(fetchWalletInputsAttachedAssets).mockResolvedValueOnce([ { inputIndex: 0, utxo: `${ZELD_A}:1`, assets: [], lookupFailed: true, pendingParentTxid: ZELD_A }, { inputIndex: 1, utxo: `${ZELD_B}:1`, assets: [], lookupFailed: true }, ]); diff --git a/src/core/zeld/spendable.ts b/src/core/zeld/spendable.ts index 2ea933fe9..1d160ceec 100644 --- a/src/core/zeld/spendable.ts +++ b/src/core/zeld/spendable.ts @@ -1,7 +1,7 @@ import { lockedOutpoints } from '@/core/bitcoin/coinLockAccess'; import { isUtxoRecentlySpent } from '@/core/bitcoin/spentUtxoCache'; import { fetchUTXOs } from '@/core/bitcoin/utxo'; -import { fetchInputsAttachedAssets } from '@/core/counterparty/inputAssets'; +import { fetchWalletInputsAttachedAssets } from '@/core/counterparty/inputAssets'; import { getActiveSettings } from '@/core/settings'; import { fetchZeldBalance, type ZeldUtxo } from '@/core/zeld/api'; @@ -34,7 +34,7 @@ export async function selectSpendableZeld( } // ZELD ownership does not prove an output is free of Counterparty attachments. // Use the same parent/ledger evidence as signing, including confirmed-but-unindexed outputs. - const evidence = await fetchInputsAttachedAssets(candidates.map((coin, index) => ({ + const evidence = await fetchWalletInputsAttachedAssets(candidates.map((coin, index) => ({ index, txid: coin.txid, vout: coin.vout, }))); const unsafe = new Set(evidence.filter(entry => entry.assets.length > 0 || entry.lookupFailed) From bc38f78b64c60474d3a15ffe4343827f275009e4 Mon Sep 17 00:00:00 2001 From: Dan Anderson Date: Tue, 10 Nov 2026 12:23:24 -0500 Subject: [PATCH 3/3] test: reuse production upgrade smoke check across releases --- e2e/tests/release-upgrade.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e/tests/release-upgrade.spec.ts b/e2e/tests/release-upgrade.spec.ts index df60f47e1..33d76bd4e 100644 --- a/e2e/tests/release-upgrade.spec.ts +++ b/e2e/tests/release-upgrade.spec.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdtempSync, renameSync } from 'node:fs'; +import { cpSync, mkdtempSync, readFileSync, renameSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { chromium, expect, test } from '@playwright/test'; @@ -13,10 +13,13 @@ import { callGalleryService } from '../utils/provider-gallery'; const from = process.env.RELEASE_UPGRADE_FROM; const to = process.env.RELEASE_UPGRADE_TO; -test('published 0.14.4 vault survives the packaged 0.15.0 upgrade', async () => { +test('published wallet vault survives the packaged upgrade', async () => { // biome-ignore lint/suspicious/noSkippedTests: Manual release check requires both production packages. test.skip(!from || !to, 'Set RELEASE_UPGRADE_FROM and RELEASE_UPGRADE_TO to unpacked production packages.'); test.setTimeout(180_000); + const fromVersion = JSON.parse(readFileSync(path.join(from!, 'manifest.json'), 'utf8')).version; + const toVersion = JSON.parse(readFileSync(path.join(to!, 'manifest.json'), 'utf8')).version; + expect(fromVersion).not.toBe(toVersion); const root = mkdtempSync(path.join(tmpdir(), 'xcp-upgrade-')); const extension = path.join(root, 'extension'); const previous = path.join(root, 'previous'); @@ -40,7 +43,7 @@ test('published 0.14.4 vault survives the packaged 0.15.0 upgrade', async () => let browser = await launch(); try { - expect(await browser.page.evaluate(() => chrome.runtime.getManifest().version)).toBe('0.14.4'); + expect(await browser.page.evaluate(() => chrome.runtime.getManifest().version)).toBe(fromVersion); await importMnemonic(browser.page); const mnemonic = await callGalleryService(browser.page, 'getActiveWallet'); await callGalleryService(browser.page, 'addAddress', [mnemonic.id]); @@ -85,7 +88,7 @@ test('published 0.14.4 vault survives the packaged 0.15.0 upgrade', async () => browser = await launch(); expect(browser.id).toBe(id); - expect(await browser.page.evaluate(() => chrome.runtime.getManifest().version)).toBe('0.15.0'); + expect(await browser.page.evaluate(() => chrome.runtime.getManifest().version)).toBe(toVersion); expect(await callGalleryService(browser.page, 'isKeychainUnlocked')).toBe(false); await unlockWallet(browser.page); expect(await callGalleryService(browser.page, 'getWallets')).toEqual(wallets);