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
11 changes: 7 additions & 4 deletions e2e/tests/release-upgrade.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand All @@ -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<Wallet>(browser.page, 'getActiveWallet');
await callGalleryService(browser.page, 'addAddress', [mnemonic.id]);
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 4 additions & 4 deletions src/components/composer/composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down
8 changes: 4 additions & 4 deletions src/contexts/__tests__/composer-context.taproot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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: () => ({
Expand Down Expand Up @@ -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();
Expand All @@ -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/);
Expand Down
38 changes: 37 additions & 1 deletion src/core/counterparty/__tests__/attachFunding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
});
});

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 }]);
});
});
Original file line number Diff line number Diff line change
@@ -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
}
}
}
94 changes: 94 additions & 0 deletions src/core/counterparty/__tests__/fundingStatus.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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';
import captured from './fixtures/core-missing-confirmations.json';

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');
});

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');
});
12 changes: 7 additions & 5 deletions src/core/counterparty/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
: {}),
};
}

Expand Down
Loading
Loading