From 74e425defe568267036ee329bee3d9dd0a430f26 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:29:35 -0500 Subject: [PATCH 1/5] feat(btc): add BTC namespace with xpub, slip132, network, tx, wallet utilities Adds a new `btc` namespace to the SDK with the following modules: - btc.slip132: SLIP-132 xpub format conversion (normalize, format, getPrefix, inferPurpose) - btc.network: Network inference from xpub (inferFromXpub, getCoinType, isTestnet) - btc.getXpub/getXpubs/getAllXpubs: Flexible xpub fetching with purpose/coinType support - btc.tx: Transaction building (buildTxReq, estimateFee) with UTXO selection - btc.wallet: Wallet utilities (getSummary, getSnapshot) for balance/UTXO aggregation - btc.provider: Blockbook provider abstraction for chain data Key design decisions: - No breaking changes: existing fetchBtcXpub/signBtcTx APIs unchanged - Uses Uint8Array/DataView instead of Node.js Buffer for browser compatibility - Provider interface allows swapping Blockbook for other backends - Types reuse existing PreviousOutput for Lattice signing compatibility Includes 41 new tests across unit, integration, and e2e test suites. --- .../sdk/src/__test__/e2e/btc-xpub.test.ts | 26 +++ .../sdk/src/__test__/integration/btc.test.ts | 49 +++++ .../sdk/src/__test__/unit/btc/network.test.ts | 48 +++++ .../unit/btc/provider/blockbook.test.ts | 150 ++++++++++++++ .../sdk/src/__test__/unit/btc/slip132.test.ts | 82 ++++++++ packages/sdk/src/__test__/unit/btc/tx.test.ts | 117 +++++++++++ .../sdk/src/__test__/unit/btc/wallet.test.ts | 108 ++++++++++ .../sdk/src/__test__/unit/btc/xpub.test.ts | 62 ++++++ packages/sdk/src/btc/constants.ts | 28 +++ packages/sdk/src/btc/index.ts | 36 ++++ packages/sdk/src/btc/network.ts | 72 +++++++ packages/sdk/src/btc/provider/blockbook.ts | 133 ++++++++++++ packages/sdk/src/btc/provider/index.ts | 2 + packages/sdk/src/btc/provider/types.ts | 100 +++++++++ packages/sdk/src/btc/slip132.ts | 106 ++++++++++ packages/sdk/src/btc/tx.ts | 190 ++++++++++++++++++ packages/sdk/src/btc/types.ts | 73 +++++++ packages/sdk/src/btc/wallet.ts | 160 +++++++++++++++ packages/sdk/src/btc/xpub.ts | 109 ++++++++++ packages/sdk/src/index.ts | 1 + 20 files changed, 1652 insertions(+) create mode 100644 packages/sdk/src/__test__/e2e/btc-xpub.test.ts create mode 100644 packages/sdk/src/__test__/integration/btc.test.ts create mode 100644 packages/sdk/src/__test__/unit/btc/network.test.ts create mode 100644 packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts create mode 100644 packages/sdk/src/__test__/unit/btc/slip132.test.ts create mode 100644 packages/sdk/src/__test__/unit/btc/tx.test.ts create mode 100644 packages/sdk/src/__test__/unit/btc/wallet.test.ts create mode 100644 packages/sdk/src/__test__/unit/btc/xpub.test.ts create mode 100644 packages/sdk/src/btc/constants.ts create mode 100644 packages/sdk/src/btc/index.ts create mode 100644 packages/sdk/src/btc/network.ts create mode 100644 packages/sdk/src/btc/provider/blockbook.ts create mode 100644 packages/sdk/src/btc/provider/index.ts create mode 100644 packages/sdk/src/btc/provider/types.ts create mode 100644 packages/sdk/src/btc/slip132.ts create mode 100644 packages/sdk/src/btc/tx.ts create mode 100644 packages/sdk/src/btc/types.ts create mode 100644 packages/sdk/src/btc/wallet.ts create mode 100644 packages/sdk/src/btc/xpub.ts diff --git a/packages/sdk/src/__test__/e2e/btc-xpub.test.ts b/packages/sdk/src/__test__/e2e/btc-xpub.test.ts new file mode 100644 index 00000000..11f3c055 --- /dev/null +++ b/packages/sdk/src/__test__/e2e/btc-xpub.test.ts @@ -0,0 +1,26 @@ +import { btc } from '../../index'; +import { setupClient } from '../utils/setup'; + +describe('BTC Xpub E2E', () => { + beforeAll(async () => { + await setupClient(); + }); + + it('fetches zpub with getXpub', async () => { + const zpub = await btc.getXpub({ purpose: 84 }); + expect(zpub).toBeTruthy(); + expect(zpub.startsWith('zpub')).toBe(true); + }); + + it('fetches all xpubs with getAllXpubs', async () => { + const xpubs = await btc.getAllXpubs(); + expect(xpubs.xpub.startsWith('xpub')).toBe(true); + expect(xpubs.ypub.startsWith('ypub')).toBe(true); + expect(xpubs.zpub.startsWith('zpub')).toBe(true); + }); + + it('fetches testnet xpub', async () => { + const tpub = await btc.getXpub({ purpose: 44, coinType: 1 }); + expect(tpub.startsWith('tpub')).toBe(true); + }); +}); diff --git a/packages/sdk/src/__test__/integration/btc.test.ts b/packages/sdk/src/__test__/integration/btc.test.ts new file mode 100644 index 00000000..6536127c --- /dev/null +++ b/packages/sdk/src/__test__/integration/btc.test.ts @@ -0,0 +1,49 @@ +import { btc } from '../../index'; + +describe('BTC Integration', () => { + describe('slip132 + network integration', () => { + it('formats and infers network correctly', () => { + const xpub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + + const zpub = btc.slip132.format(xpub, 84, 'mainnet'); + expect(zpub.startsWith('zpub')).toBe(true); + + const network = btc.network.inferFromXpub(zpub); + expect(network).toBe('mainnet'); + + const purpose = btc.slip132.inferPurpose(zpub); + expect(purpose).toBe(84); + }); + }); + + describe('tx + wallet integration', () => { + it('builds transaction from wallet UTXOs', () => { + const utxos: btc.WalletUtxo[] = [ + { + txid: 'abc123', + vout: 0, + value: 100000, + confirmations: 6, + address: 'bc1q...', + path: [0x80000054, 0x80000000, 0x80000000, 0, 0], + scriptType: 'p2wpkh', + }, + ]; + + const result = btc.buildTxReq({ + utxos, + recipient: 'bc1qtest...', + value: 50000, + feeRate: 10, + purpose: 84, + changeIndex: 0, + }); + + expect(result.value).toBe(50000); + expect(result.prevOuts).toHaveLength(1); + expect(result.changeValue).toBeGreaterThan(0); + expect(result.fee).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/sdk/src/__test__/unit/btc/network.test.ts b/packages/sdk/src/__test__/unit/btc/network.test.ts new file mode 100644 index 00000000..5395def7 --- /dev/null +++ b/packages/sdk/src/__test__/unit/btc/network.test.ts @@ -0,0 +1,48 @@ +import bs58check from 'bs58check'; +import { SLIP132_VERSION_BYTES } from '../../../btc/constants'; +import { + inferFromXpub, + getCoinType, + getNetworkFromCoinType, + isTestnet, +} from '../../../btc/network'; + +const TEST_XPUB = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; +const toVersion = (xpub: string, version: number) => { + const decoded = Buffer.from(bs58check.decode(xpub)); + decoded.writeUInt32BE(version, 0); + return bs58check.encode(decoded); +}; + +const TEST_TPUB = toVersion(TEST_XPUB, SLIP132_VERSION_BYTES.tpub.public); + +describe('btc/network', () => { + it('infers mainnet from xpub', () => { + expect(inferFromXpub(TEST_XPUB)).toBe('mainnet'); + }); + + it('infers testnet from tpub', () => { + expect(inferFromXpub(TEST_TPUB)).toBe('testnet'); + }); + + it('returns coin type for mainnet', () => { + expect(getCoinType('mainnet')).toBe(0); + }); + + it('returns coin type for testnet/regtest', () => { + expect(getCoinType('testnet')).toBe(1); + expect(getCoinType('regtest')).toBe(1); + }); + + it('returns network from coin type', () => { + expect(getNetworkFromCoinType(0)).toBe('mainnet'); + expect(getNetworkFromCoinType(1)).toBe('testnet'); + }); + + it('identifies testnet-like networks', () => { + expect(isTestnet('mainnet')).toBe(false); + expect(isTestnet('testnet')).toBe(true); + expect(isTestnet('regtest')).toBe(true); + }); +}); diff --git a/packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts b/packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts new file mode 100644 index 00000000..9287aa93 --- /dev/null +++ b/packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts @@ -0,0 +1,150 @@ +import { + BlockbookProvider, + createBlockbookProvider, +} from '../../../../btc/provider/blockbook'; + +const buildResponse = (data: unknown, ok = true, status = 200) => ({ + ok, + status, + json: async () => data, + text: async () => JSON.stringify(data), +}); + +describe('btc/provider/blockbook', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('uses baseUrl without trailing slash', async () => { + const provider = new BlockbookProvider({ baseUrl: 'https://example.com/' }); + fetchMock.mockResolvedValueOnce( + buildResponse({ + address: 'xpub', + balance: '0', + totalReceived: '0', + totalSent: '0', + unconfirmedBalance: '0', + unconfirmedTxs: 0, + txs: 0, + }), + ); + + await provider.getSummary('xpub123'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/v2/xpub/xpub123?details=basic', + expect.any(Object), + ); + }); + + it('fetches summary data', async () => { + const provider = createBlockbookProvider({ + baseUrl: 'https://example.com', + }); + fetchMock.mockResolvedValueOnce( + buildResponse({ + address: 'xpub', + balance: '100', + totalReceived: '150', + totalSent: '50', + unconfirmedBalance: '0', + unconfirmedTxs: 0, + txs: 2, + }), + ); + + const summary = await provider.getSummary('xpub123'); + expect(summary.balance).toBe('100'); + }); + + it('fetches transactions with paging', async () => { + const provider = new BlockbookProvider({ baseUrl: 'https://example.com' }); + fetchMock.mockResolvedValueOnce( + buildResponse({ + transactions: [ + { + txid: 'tx1', + version: 1, + vin: [], + vout: [], + blockHeight: 1, + confirmations: 1, + blockTime: 0, + value: '0', + valueIn: '0', + fees: '0', + }, + ], + }), + ); + + const txs = await provider.getTransactions('xpub123', { + page: 2, + pageSize: 10, + }); + expect(fetchMock.mock.calls[0][0]).toContain('page=2'); + expect(fetchMock.mock.calls[0][0]).toContain('pageSize=10'); + expect(txs).toHaveLength(1); + }); + + it('fetches UTXOs', async () => { + const provider = new BlockbookProvider({ baseUrl: 'https://example.com' }); + fetchMock.mockResolvedValueOnce( + buildResponse([ + { + txid: 'tx1', + vout: 0, + value: '1000', + height: 1, + confirmations: 1, + }, + ]), + ); + + const utxos = await provider.getUtxos('xpub123'); + expect(utxos).toHaveLength(1); + }); + + it('broadcasts a raw transaction', async () => { + const provider = new BlockbookProvider({ baseUrl: 'https://example.com' }); + fetchMock.mockResolvedValueOnce(buildResponse({ result: 'txid123' })); + + const txid = await provider.broadcast('rawtx'); + expect(txid).toBe('txid123'); + }); + + it('throws on non-ok responses', async () => { + const provider = new BlockbookProvider({ baseUrl: 'https://example.com' }); + fetchMock.mockResolvedValueOnce(buildResponse({ error: 'bad' }, false, 500)); + + await expect(provider.getSummary('xpub123')).rejects.toThrow( + 'Blockbook request failed: 500', + ); + }); + + it('fetches fee rates with conversions', async () => { + const provider = new BlockbookProvider({ baseUrl: 'https://example.com' }); + fetchMock.mockImplementation((url: string) => { + if (url.includes('/estimatefee/2')) { + return Promise.resolve(buildResponse({ result: '0.00002' })); + } + if (url.includes('/estimatefee/6')) { + return Promise.resolve(buildResponse({ result: '0.00003' })); + } + if (url.includes('/estimatefee/12')) { + return Promise.resolve(buildResponse({ result: '0.00001' })); + } + return Promise.resolve(buildResponse({})); + }); + + const fees = await provider.getFeeRates(); + expect(fees).toEqual({ fast: 2, medium: 3, slow: 1 }); + }); +}); diff --git a/packages/sdk/src/__test__/unit/btc/slip132.test.ts b/packages/sdk/src/__test__/unit/btc/slip132.test.ts new file mode 100644 index 00000000..0a0b4286 --- /dev/null +++ b/packages/sdk/src/__test__/unit/btc/slip132.test.ts @@ -0,0 +1,82 @@ +import bs58check from 'bs58check'; +import { SLIP132_VERSION_BYTES } from '../../../btc/constants'; +import { + getPrefix, + normalize, + format, + inferPurpose, + getVersionBytes, +} from '../../../btc/slip132'; + +const TEST_XPUB = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; +const toVersion = (xpub: string, version: number) => { + const decoded = Buffer.from(bs58check.decode(xpub)); + decoded.writeUInt32BE(version, 0); + return bs58check.encode(decoded); +}; + +const TEST_TPUB = toVersion(TEST_XPUB, SLIP132_VERSION_BYTES.tpub.public); + +describe('btc/slip132', () => { + describe('getPrefix', () => { + it('identifies xpub prefix', () => { + expect(getPrefix(TEST_XPUB)).toBe('xpub'); + }); + + it('identifies tpub prefix', () => { + expect(getPrefix(TEST_TPUB)).toBe('tpub'); + }); + + it('throws for invalid xpub', () => { + expect(() => getPrefix('invalid')).toThrow(); + }); + }); + + describe('getVersionBytes', () => { + it('extracts version bytes from xpub', () => { + expect(getVersionBytes(TEST_XPUB)).toBe(0x0488b21e); + }); + }); + + describe('normalize', () => { + it('normalizes xpub to xpub (no change)', () => { + const result = normalize(TEST_XPUB); + expect(result.startsWith('xpub')).toBe(true); + }); + + it('normalizes ypub to xpub', () => { + const ypub = format(TEST_XPUB, 49, 'mainnet'); + const result = normalize(ypub); + expect(result.startsWith('xpub')).toBe(true); + }); + }); + + describe('format', () => { + it('formats xpub to ypub for purpose 49', () => { + const result = format(TEST_XPUB, 49, 'mainnet'); + expect(result.startsWith('ypub')).toBe(true); + }); + + it('formats xpub to zpub for purpose 84', () => { + const result = format(TEST_XPUB, 84, 'mainnet'); + expect(result.startsWith('zpub')).toBe(true); + }); + + it('formats to testnet prefix for testnet network', () => { + const result = format(TEST_XPUB, 44, 'testnet'); + expect(result.startsWith('tpub')).toBe(true); + }); + }); + + describe('inferPurpose', () => { + it('infers purpose 44 from xpub', () => { + expect(inferPurpose(TEST_XPUB)).toBe(44); + }); + + it('infers purpose 84 from zpub', () => { + const zpub = format(TEST_XPUB, 84, 'mainnet'); + expect(inferPurpose(zpub)).toBe(84); + }); + }); +}); diff --git a/packages/sdk/src/__test__/unit/btc/tx.test.ts b/packages/sdk/src/__test__/unit/btc/tx.test.ts new file mode 100644 index 00000000..6de9cc2a --- /dev/null +++ b/packages/sdk/src/__test__/unit/btc/tx.test.ts @@ -0,0 +1,117 @@ +import type { WalletUtxo } from '../../../btc/types'; +import { buildTxReq, estimateFee } from '../../../btc/tx'; +import { HARDENED_OFFSET } from '../../../constants'; + +const utxos: WalletUtxo[] = [ + { + txid: 'tx1', + vout: 0, + value: 100000, + confirmations: 6, + address: 'bc1qtest', + path: [ + HARDENED_OFFSET + 84, + HARDENED_OFFSET, + HARDENED_OFFSET, + 0, + 0, + ], + scriptType: 'p2wpkh', + }, +]; + +describe('btc/tx', () => { + it('estimates fee for a standard segwit tx', () => { + const fee = estimateFee(1, 10, 'p2wpkh'); + expect(fee).toBe(1420); + }); + + it('builds a tx request with change', () => { + const result = buildTxReq({ + utxos, + recipient: 'bc1qrecipient', + value: 50000, + feeRate: 10, + purpose: 84, + changeIndex: 5, + }); + + expect(result.prevOuts).toHaveLength(1); + expect(result.fee).toBe(1420); + expect(result.changeValue).toBe(48580); + expect(result.changePath).toEqual([ + HARDENED_OFFSET + 84, + HARDENED_OFFSET, + HARDENED_OFFSET, + 1, + 5, + ]); + }); + + it('selects the largest UTXO first', () => { + const result = buildTxReq({ + utxos: [ + { ...utxos[0], txid: 'small', value: 20000 }, + { ...utxos[0], txid: 'large', value: 80000 }, + ], + recipient: 'bc1qrecipient', + value: 50000, + feeRate: 10, + purpose: 84, + changeIndex: 0, + }); + + expect(result.prevOuts).toHaveLength(1); + expect(result.prevOuts[0].txHash).toBe('large'); + }); + + it('throws for empty UTXO set', () => { + expect(() => + buildTxReq({ + utxos: [], + recipient: 'bc1qrecipient', + value: 50000, + feeRate: 10, + purpose: 84, + changeIndex: 0, + }), + ).toThrow('No UTXOs available'); + }); + + it('throws for invalid value or fee rate', () => { + expect(() => + buildTxReq({ + utxos, + recipient: 'bc1qrecipient', + value: 0, + feeRate: 10, + purpose: 84, + changeIndex: 0, + }), + ).toThrow('Value must be positive'); + + expect(() => + buildTxReq({ + utxos, + recipient: 'bc1qrecipient', + value: 1000, + feeRate: 0, + purpose: 84, + changeIndex: 0, + }), + ).toThrow('Fee rate must be positive'); + }); + + it('throws when funds are insufficient', () => { + expect(() => + buildTxReq({ + utxos, + recipient: 'bc1qrecipient', + value: 99000, + feeRate: 10, + purpose: 84, + changeIndex: 0, + }), + ).toThrow('Insufficient funds'); + }); +}); diff --git a/packages/sdk/src/__test__/unit/btc/wallet.test.ts b/packages/sdk/src/__test__/unit/btc/wallet.test.ts new file mode 100644 index 00000000..43f8d18c --- /dev/null +++ b/packages/sdk/src/__test__/unit/btc/wallet.test.ts @@ -0,0 +1,108 @@ +import type { BtcProvider } from '../../../btc/provider/types'; +import { getSnapshot, getSummary } from '../../../btc/wallet'; +import { HARDENED_OFFSET } from '../../../constants'; + +const provider: BtcProvider = { + getSummary: vi.fn(), + getTransactions: vi.fn(), + getUtxos: vi.fn(), + broadcast: vi.fn(), + getFeeRates: vi.fn(), +}; + +describe('btc/wallet', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns wallet summary with parsed values', async () => { + (provider.getSummary as any).mockResolvedValue({ + address: 'xpub', + balance: '100000', + totalReceived: '150000', + totalSent: '50000', + unconfirmedBalance: '0', + unconfirmedTxs: 0, + txs: 3, + }); + (provider.getUtxos as any).mockResolvedValue([ + { + txid: 'tx1', + vout: 0, + value: '50000', + height: 1, + confirmations: 6, + }, + ]); + + const summary = await getSummary({ xpub: 'xpub', provider }); + expect(summary.balance).toBe(100000); + expect(summary.txCount).toBe(3); + expect(summary.utxoCount).toBe(1); + }); + + it('builds a wallet snapshot with address info', async () => { + (provider.getSummary as any).mockResolvedValue({ + address: 'xpub', + balance: '100000', + totalReceived: '150000', + totalSent: '50000', + unconfirmedBalance: '0', + unconfirmedTxs: 0, + txs: 3, + tokens: [ + { + type: 'XPUBAddress', + name: 'bc1qreceive', + path: "m/84'/0'/0'/0/3", + transfers: 1, + decimals: 8, + balance: '0', + totalReceived: '0', + totalSent: '0', + }, + { + type: 'XPUBAddress', + name: 'bc1qchange', + path: "m/84'/0'/0'/1/1", + transfers: 1, + decimals: 8, + balance: '0', + totalReceived: '0', + totalSent: '0', + }, + ], + }); + (provider.getUtxos as any).mockResolvedValue([ + { + txid: 'tx1', + vout: 0, + value: '50000', + height: 1, + confirmations: 6, + address: 'bc1qutxo', + path: "m/84'/0'/0'/0/0", + }, + ]); + + const snapshot = await getSnapshot({ + xpub: 'xpub', + provider, + purpose: 84, + }); + + expect(snapshot.utxos).toHaveLength(1); + expect(snapshot.utxos[0].scriptType).toBe('p2wpkh'); + expect(snapshot.utxos[0].path).toEqual([ + HARDENED_OFFSET + 84, + HARDENED_OFFSET + 0, + HARDENED_OFFSET + 0, + 0, + 0, + ]); + expect(snapshot.addresses.receiving).toEqual(['bc1qreceive']); + expect(snapshot.addresses.change).toEqual(['bc1qchange']); + expect(snapshot.nextReceiveIndex).toBe(4); + expect(snapshot.nextChangeIndex).toBe(2); + }); +}); diff --git a/packages/sdk/src/__test__/unit/btc/xpub.test.ts b/packages/sdk/src/__test__/unit/btc/xpub.test.ts new file mode 100644 index 00000000..abceaa6e --- /dev/null +++ b/packages/sdk/src/__test__/unit/btc/xpub.test.ts @@ -0,0 +1,62 @@ +vi.mock('../../../api/utilities', () => ({ + queue: vi.fn(), +})); + +import { BTC_COIN_TYPES, BTC_PURPOSES } from '../../../btc/constants'; +import { getAllXpubs, getXpub, getXpubs } from '../../../btc/xpub'; +import { queue } from '../../../api/utilities'; + +const TEST_XPUB = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + +const mockQueue = vi.mocked(queue); + +describe('btc/xpub', () => { + beforeEach(() => { + mockQueue.mockResolvedValue([TEST_XPUB]); + }); + + it('returns legacy xpub for purpose 44', async () => { + const result = await getXpub({ purpose: 44 }); + expect(result.startsWith('xpub')).toBe(true); + }); + + it('returns ypub for purpose 49', async () => { + const result = await getXpub({ purpose: 49 }); + expect(result.startsWith('ypub')).toBe(true); + }); + + it('returns zpub for purpose 84', async () => { + const result = await getXpub({ purpose: 84 }); + expect(result.startsWith('zpub')).toBe(true); + }); + + it('returns tpub for testnet coin type', async () => { + const result = await getXpub({ + purpose: BTC_PURPOSES.LEGACY, + coinType: BTC_COIN_TYPES.TESTNET, + }); + expect(result.startsWith('tpub')).toBe(true); + }); + + it('throws when no xpub is returned', async () => { + mockQueue.mockResolvedValueOnce([]); + await expect(getXpub({ purpose: 44 })).rejects.toThrow( + 'Failed to fetch xpub from device', + ); + }); + + it('fetches multiple xpubs by purpose', async () => { + const results = await getXpubs({ purposes: [44, 49, 84] }); + expect(results.get(44)?.startsWith('xpub')).toBe(true); + expect(results.get(49)?.startsWith('ypub')).toBe(true); + expect(results.get(84)?.startsWith('zpub')).toBe(true); + }); + + it('fetches all standard xpubs', async () => { + const results = await getAllXpubs(); + expect(results.xpub.startsWith('xpub')).toBe(true); + expect(results.ypub.startsWith('ypub')).toBe(true); + expect(results.zpub.startsWith('zpub')).toBe(true); + }); +}); diff --git a/packages/sdk/src/btc/constants.ts b/packages/sdk/src/btc/constants.ts new file mode 100644 index 00000000..aeab87ee --- /dev/null +++ b/packages/sdk/src/btc/constants.ts @@ -0,0 +1,28 @@ +export const SLIP132_VERSION_BYTES = { + // Mainnet + xpub: { public: 0x0488b21e, private: 0x0488ade4 }, // BIP44 - Legacy (P2PKH) + ypub: { public: 0x049d7cb2, private: 0x049d7878 }, // BIP49 - Wrapped SegWit (P2SH-P2WPKH) + zpub: { public: 0x04b24746, private: 0x04b2430c }, // BIP84 - Native SegWit (P2WPKH) + + // Testnet + tpub: { public: 0x043587cf, private: 0x04358394 }, // BIP44 - Legacy (P2PKH) + upub: { public: 0x044a5262, private: 0x044a4e28 }, // BIP49 - Wrapped SegWit (P2SH-P2WPKH) + vpub: { public: 0x045f1cf6, private: 0x045f18bc }, // BIP84 - Native SegWit (P2WPKH) +} as const; + +export const BTC_PURPOSES = { + LEGACY: 44, // BIP44 - P2PKH + WRAPPED: 49, // BIP49 - P2SH-P2WPKH + NATIVE: 84, // BIP84 - P2WPKH +} as const; + +export const BTC_COIN_TYPES = { + MAINNET: 0, + TESTNET: 1, +} as const; + +export const BTC_NETWORKS = { + MAINNET: 'mainnet', + TESTNET: 'testnet', + REGTEST: 'regtest', +} as const; diff --git a/packages/sdk/src/btc/index.ts b/packages/sdk/src/btc/index.ts new file mode 100644 index 00000000..4554abb7 --- /dev/null +++ b/packages/sdk/src/btc/index.ts @@ -0,0 +1,36 @@ +export { + SLIP132_VERSION_BYTES, + BTC_PURPOSES, + BTC_COIN_TYPES, + BTC_NETWORKS, +} from './constants'; + +export type { + BtcPurpose, + BtcCoinType, + BtcNetwork, + XpubPrefix, + XpubOptions, + XpubsOptions, + WalletUtxo, + WalletSummary, + WalletSnapshot, + TxBuildInput, + TxBuildResult, +} from './types'; + +export * as slip132 from './slip132'; +export * as network from './network'; + +export { getXpub, getXpubs, getAllXpubs } from './xpub'; +export { buildTxReq, estimateFee } from './tx'; +export { getSummary, getSnapshot } from './wallet'; + +export * as provider from './provider'; +export { createBlockbookProvider } from './provider/blockbook'; +export type { + BtcProvider, + BlockbookProviderConfig, + FeeRates, + PagingOptions, +} from './provider/types'; diff --git a/packages/sdk/src/btc/network.ts b/packages/sdk/src/btc/network.ts new file mode 100644 index 00000000..f0a33811 --- /dev/null +++ b/packages/sdk/src/btc/network.ts @@ -0,0 +1,72 @@ +import { BTC_COIN_TYPES, BTC_NETWORKS, SLIP132_VERSION_BYTES } from './constants'; +import type { BtcCoinType, BtcNetwork } from './types'; +import { getVersionBytes } from './slip132'; + +/** + * Infer the network (mainnet/testnet) from an extended public key. + */ +export function inferFromXpub(xpub: string): BtcNetwork { + const version = getVersionBytes(xpub); + + const mainnetVersions = new Set([ + SLIP132_VERSION_BYTES.xpub.public, + SLIP132_VERSION_BYTES.ypub.public, + SLIP132_VERSION_BYTES.zpub.public, + ]); + if (mainnetVersions.has(version)) { + return BTC_NETWORKS.MAINNET; + } + + const testnetVersions = new Set([ + SLIP132_VERSION_BYTES.tpub.public, + SLIP132_VERSION_BYTES.upub.public, + SLIP132_VERSION_BYTES.vpub.public, + ]); + if (testnetVersions.has(version)) { + return BTC_NETWORKS.TESTNET; + } + + throw new Error(`Unknown xpub version: 0x${version.toString(16)}`); +} + +/** + * Get the BIP44 coin type for a given network. + */ +export function getCoinType(network: BtcNetwork): BtcCoinType; +export function getCoinType(override: BtcCoinType): BtcCoinType; +export function getCoinType(networkOrOverride: BtcNetwork | BtcCoinType): BtcCoinType { + if (typeof networkOrOverride === 'number') { + return networkOrOverride; + } + + switch (networkOrOverride) { + case BTC_NETWORKS.MAINNET: + return BTC_COIN_TYPES.MAINNET; + case BTC_NETWORKS.TESTNET: + case BTC_NETWORKS.REGTEST: + return BTC_COIN_TYPES.TESTNET; + default: + throw new Error(`Unknown network: ${networkOrOverride}`); + } +} + +/** + * Get network name from coin type. + */ +export function getNetworkFromCoinType(coinType: BtcCoinType): BtcNetwork { + switch (coinType) { + case BTC_COIN_TYPES.MAINNET: + return BTC_NETWORKS.MAINNET; + case BTC_COIN_TYPES.TESTNET: + return BTC_NETWORKS.TESTNET; + default: + throw new Error(`Unknown coin type: ${coinType}`); + } +} + +/** + * Check if a network is testnet-like (testnet or regtest). + */ +export function isTestnet(network: BtcNetwork): boolean { + return network === BTC_NETWORKS.TESTNET || network === BTC_NETWORKS.REGTEST; +} diff --git a/packages/sdk/src/btc/provider/blockbook.ts b/packages/sdk/src/btc/provider/blockbook.ts new file mode 100644 index 00000000..6c1e168c --- /dev/null +++ b/packages/sdk/src/btc/provider/blockbook.ts @@ -0,0 +1,133 @@ +import type { + BtcProvider, + BlockbookProviderConfig, + BlockbookSummary, + BlockbookTransaction, + BlockbookUtxo, + FeeRates, + PagingOptions, +} from './types'; + +const DEFAULT_BLOCKBOOK_URLS = { + mainnet: 'https://btc1.trezor.io', + testnet: 'https://tbtc1.trezor.io', +} as const; + +/** + * Blockbook provider for Bitcoin chain data. + */ +export class BlockbookProvider implements BtcProvider { + private baseUrl: string; + + constructor(config?: BlockbookProviderConfig) { + if (config?.baseUrl) { + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + } else { + const network = config?.network ?? 'mainnet'; + this.baseUrl = DEFAULT_BLOCKBOOK_URLS[network]; + } + } + + /** + * Get account summary for an xpub. + */ + async getSummary(xpub: string): Promise { + const response = await this.fetch(`/api/v2/xpub/${xpub}?details=basic`); + return response as BlockbookSummary; + } + + /** + * Get transaction history for an xpub. + */ + async getTransactions( + xpub: string, + options: PagingOptions = {}, + ): Promise { + const { page = 1, pageSize = 50 } = options; + const params = new URLSearchParams({ + details: 'txs', + page: String(page), + pageSize: String(pageSize), + }); + + const response = await this.fetch(`/api/v2/xpub/${xpub}?${params}`); + return (response as any).transactions ?? []; + } + + /** + * Get unspent transaction outputs for an xpub. + */ + async getUtxos(xpub: string): Promise { + const response = await this.fetch(`/api/v2/utxo/${xpub}`); + return response as BlockbookUtxo[]; + } + + /** + * Broadcast a signed transaction. + */ + async broadcast(rawTx: string): Promise { + const response = await this.fetch('/api/v2/sendtx/', { + method: 'POST', + body: rawTx, + }); + + if (typeof response === 'object' && response && 'result' in response) { + return (response as any).result; + } + throw new Error('Unexpected broadcast response'); + } + + /** + * Get current fee rate estimates. + * Blockbook returns estimates for different confirmation targets. + */ + async getFeeRates(): Promise { + const [fast, medium, slow] = await Promise.all([ + this.fetchFeeEstimate(2), + this.fetchFeeEstimate(6), + this.fetchFeeEstimate(12), + ]); + + return { + fast: Math.ceil(fast), + medium: Math.ceil(medium), + slow: Math.ceil(slow), + }; + } + + private async fetchFeeEstimate(blocks: number): Promise { + const response = await this.fetch(`/api/v2/estimatefee/${blocks}`); + const btcPerKb = parseFloat((response as any).result); + if (isNaN(btcPerKb) || btcPerKb <= 0) { + return 1; + } + return btcPerKb * 100000; + } + + private async fetch(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Blockbook request failed: ${response.status} ${text}`); + } + + return response.json(); + } +} + +/** + * Create a Blockbook provider instance. + */ +export function createBlockbookProvider( + config?: BlockbookProviderConfig, +): BtcProvider { + return new BlockbookProvider(config); +} diff --git a/packages/sdk/src/btc/provider/index.ts b/packages/sdk/src/btc/provider/index.ts new file mode 100644 index 00000000..49a2737a --- /dev/null +++ b/packages/sdk/src/btc/provider/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './blockbook'; diff --git a/packages/sdk/src/btc/provider/types.ts b/packages/sdk/src/btc/provider/types.ts new file mode 100644 index 00000000..783656e9 --- /dev/null +++ b/packages/sdk/src/btc/provider/types.ts @@ -0,0 +1,100 @@ +/** UTXO as returned by Blockbook */ +export interface BlockbookUtxo { + txid: string; + vout: number; + value: string; + height: number; + confirmations: number; + address?: string; + path?: string; +} + +/** Transaction as returned by Blockbook */ +export interface BlockbookTransaction { + txid: string; + version: number; + vin: Array<{ + txid: string; + vout: number; + sequence: number; + addresses: string[]; + value: string; + }>; + vout: Array<{ + value: string; + n: number; + addresses: string[]; + isOwn?: boolean; + }>; + blockHeight: number; + confirmations: number; + blockTime: number; + value: string; + valueIn: string; + fees: string; +} + +/** Address/Xpub summary from Blockbook */ +export interface BlockbookSummary { + address: string; + balance: string; + totalReceived: string; + totalSent: string; + unconfirmedBalance: string; + unconfirmedTxs: number; + txs: number; + usedTokens?: number; + tokens?: Array<{ + type: string; + name: string; + path: string; + transfers: number; + decimals: number; + balance: string; + totalReceived: string; + totalSent: string; + }>; +} + +/** Paging options for transaction queries */ +export interface PagingOptions { + page?: number; + pageSize?: number; + from?: number; + to?: number; +} + +/** Fee rate estimates */ +export interface FeeRates { + fast: number; // sats/vbyte - ~10 min confirmation + medium: number; // sats/vbyte - ~30 min confirmation + slow: number; // sats/vbyte - ~60 min confirmation + economy?: number; // sats/vbyte - lowest priority +} + +/** BTC Provider interface - abstraction over chain data sources */ +export interface BtcProvider { + /** Get account summary for an xpub */ + getSummary(xpub: string): Promise; + + /** Get transaction history for an xpub */ + getTransactions( + xpub: string, + options?: PagingOptions, + ): Promise; + + /** Get unspent transaction outputs for an xpub */ + getUtxos(xpub: string): Promise; + + /** Broadcast a signed transaction */ + broadcast(rawTx: string): Promise; + + /** Get current fee rate estimates */ + getFeeRates(): Promise; +} + +/** Provider configuration */ +export interface BlockbookProviderConfig { + baseUrl?: string; + network?: 'mainnet' | 'testnet'; +} diff --git a/packages/sdk/src/btc/slip132.ts b/packages/sdk/src/btc/slip132.ts new file mode 100644 index 00000000..856e4eca --- /dev/null +++ b/packages/sdk/src/btc/slip132.ts @@ -0,0 +1,106 @@ +import bs58check from 'bs58check'; +import { SLIP132_VERSION_BYTES, BTC_PURPOSES } from './constants'; +import type { BtcNetwork, BtcPurpose, XpubPrefix } from './types'; + +/** + * Get the 4-byte version prefix from an extended public key. + */ +export function getVersionBytes(xpub: string): number { + const decoded = bs58check.decode(xpub); + const view = new DataView(decoded.buffer, decoded.byteOffset, decoded.byteLength); + return view.getUint32(0, false); // false = big-endian +} + +/** + * Get the prefix string (xpub, ypub, zpub, etc.) from an extended public key. + */ +export function getPrefix(xpub: string): XpubPrefix { + const version = getVersionBytes(xpub); + for (const [prefix, bytes] of Object.entries(SLIP132_VERSION_BYTES)) { + if (bytes.public === version) { + return prefix as XpubPrefix; + } + } + throw new Error(`Unknown xpub version: 0x${version.toString(16)}`); +} + +/** + * Normalize any extended public key to standard xpub/tpub format. + * This strips SLIP-132 encoding (ypub/zpub/upub/vpub) back to base format. + */ +export function normalize(xpub: string): string { + const prefix = getPrefix(xpub); + const isTestnet = ['tpub', 'upub', 'vpub'].includes(prefix); + const targetVersion = isTestnet + ? SLIP132_VERSION_BYTES.tpub.public + : SLIP132_VERSION_BYTES.xpub.public; + + return convertVersion(xpub, targetVersion); +} + +/** + * Format an extended public key with the appropriate SLIP-132 prefix + * based on purpose and network. + */ +export function format( + xpub: string, + purpose: BtcPurpose, + network: BtcNetwork = 'mainnet', +): string { + const isTestnet = network !== 'mainnet'; + + let targetVersion: number; + switch (purpose) { + case BTC_PURPOSES.LEGACY: + targetVersion = isTestnet + ? SLIP132_VERSION_BYTES.tpub.public + : SLIP132_VERSION_BYTES.xpub.public; + break; + case BTC_PURPOSES.WRAPPED: + targetVersion = isTestnet + ? SLIP132_VERSION_BYTES.upub.public + : SLIP132_VERSION_BYTES.ypub.public; + break; + case BTC_PURPOSES.NATIVE: + targetVersion = isTestnet + ? SLIP132_VERSION_BYTES.vpub.public + : SLIP132_VERSION_BYTES.zpub.public; + break; + default: + throw new Error(`Unknown purpose: ${purpose}`); + } + + return convertVersion(xpub, targetVersion); +} + +/** + * Convert an extended public key to a different version. + */ +function convertVersion(xpub: string, targetVersion: number): string { + const decoded = bs58check.decode(xpub); + const converted = new Uint8Array(decoded.length); + const view = new DataView(converted.buffer); + view.setUint32(0, targetVersion, false); // false = big-endian + converted.set(decoded.subarray(4), 4); // copy bytes after version + return bs58check.encode(converted); +} + +/** + * Infer the BTC purpose from an extended public key prefix. + */ +export function inferPurpose(xpub: string): BtcPurpose { + const prefix = getPrefix(xpub); + switch (prefix) { + case 'xpub': + case 'tpub': + return BTC_PURPOSES.LEGACY; + case 'ypub': + case 'upub': + return BTC_PURPOSES.WRAPPED; + case 'zpub': + case 'vpub': + return BTC_PURPOSES.NATIVE; + default: + throw new Error(`Cannot infer purpose from prefix: ${prefix}`); + } +} diff --git a/packages/sdk/src/btc/tx.ts b/packages/sdk/src/btc/tx.ts new file mode 100644 index 00000000..2cb52107 --- /dev/null +++ b/packages/sdk/src/btc/tx.ts @@ -0,0 +1,190 @@ +import { HARDENED_OFFSET } from '../constants'; +import { BTC_COIN_TYPES } from './constants'; +import type { + TxBuildInput, + TxBuildResult, + WalletUtxo, + BtcPurpose, + BtcCoinType, +} from './types'; + +const VBYTE_SIZES = { + P2PKH_INPUT: 148, + P2SH_P2WPKH_INPUT: 91, + P2WPKH_INPUT: 68, + P2PKH_OUTPUT: 34, + P2SH_OUTPUT: 32, + P2WPKH_OUTPUT: 31, + VERSION: 4, + LOCKTIME: 4, + SEGWIT_MARKER: 2, + INPUT_COUNT: 1, + OUTPUT_COUNT: 1, +} as const; + +/** + * Estimate transaction size in virtual bytes. + */ +function estimateTxVbytes( + inputCount: number, + outputCount: number, + inputType: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh', +): number { + let inputSize: number; + switch (inputType) { + case 'p2pkh': + inputSize = VBYTE_SIZES.P2PKH_INPUT; + break; + case 'p2sh-p2wpkh': + inputSize = VBYTE_SIZES.P2SH_P2WPKH_INPUT; + break; + case 'p2wpkh': + inputSize = VBYTE_SIZES.P2WPKH_INPUT; + break; + } + + const overhead = + VBYTE_SIZES.VERSION + + VBYTE_SIZES.LOCKTIME + + VBYTE_SIZES.INPUT_COUNT + + VBYTE_SIZES.OUTPUT_COUNT; + + const hasSegwit = inputType !== 'p2pkh'; + const segwitOverhead = hasSegwit ? VBYTE_SIZES.SEGWIT_MARKER : 0; + + const outputSize = VBYTE_SIZES.P2WPKH_OUTPUT; + + return ( + overhead + + segwitOverhead + + inputCount * inputSize + + outputCount * outputSize + ); +} + +/** + * Select UTXOs for a transaction using simple largest-first strategy. + */ +function selectUtxos( + utxos: WalletUtxo[], + targetValue: number, + feeRate: number, +): { selected: WalletUtxo[]; fee: number } { + const sorted = [...utxos].sort((a, b) => b.value - a.value); + + const selected: WalletUtxo[] = []; + let totalValue = 0; + + for (const utxo of sorted) { + selected.push(utxo); + totalValue += utxo.value; + + const vbytes = estimateTxVbytes(selected.length, 2, selected[0].scriptType); + const estimatedFee = Math.ceil(vbytes * feeRate); + + if (totalValue >= targetValue + estimatedFee) { + return { selected, fee: estimatedFee }; + } + } + + throw new Error( + `Insufficient funds: have ${totalValue} sats, need ${targetValue} + fees`, + ); +} + +/** + * Build derivation path for change address. + */ +function buildChangePath( + purpose: BtcPurpose, + coinType: BtcCoinType, + changeIndex: number, +): number[] { + return [ + purpose + HARDENED_OFFSET, + coinType + HARDENED_OFFSET, + HARDENED_OFFSET, + 1, + changeIndex, + ]; +} + +/** + * Build a transaction request from wallet UTXOs. + * + * @param input - Transaction building parameters + * @returns Transaction request ready for signing + * + * @example + * const txReq = buildTxReq({ + * utxos: walletSnapshot.utxos, + * recipient: 'bc1q...', + * value: 50000, + * feeRate: 10, + * purpose: 84, + * changeIndex: 5, + * }); + */ +export function buildTxReq(input: TxBuildInput): TxBuildResult { + const { + utxos, + recipient, + value, + feeRate, + purpose, + coinType = BTC_COIN_TYPES.MAINNET, + changeIndex, + } = input; + + if (utxos.length === 0) { + throw new Error('No UTXOs available'); + } + + if (value <= 0) { + throw new Error('Value must be positive'); + } + + if (feeRate <= 0) { + throw new Error('Fee rate must be positive'); + } + + const { selected, fee } = selectUtxos(utxos, value, feeRate); + + const totalInput = selected.reduce((sum, utxo) => sum + utxo.value, 0); + const changeValue = totalInput - value - fee; + + if (changeValue < 0) { + throw new Error('Insufficient funds after fee calculation'); + } + + const prevOuts = selected.map((utxo) => ({ + txHash: utxo.txid, + value: utxo.value, + index: utxo.vout, + signerPath: utxo.path, + })); + + const changePath = buildChangePath(purpose, coinType, changeIndex); + + return { + prevOuts, + recipient, + value, + fee, + changePath, + changeValue, + totalInput, + }; +} + +/** + * Estimate fee for a transaction. + */ +export function estimateFee( + utxoCount: number, + feeRate: number, + inputType: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' = 'p2wpkh', +): number { + const vbytes = estimateTxVbytes(utxoCount, 2, inputType); + return Math.ceil(vbytes * feeRate); +} diff --git a/packages/sdk/src/btc/types.ts b/packages/sdk/src/btc/types.ts new file mode 100644 index 00000000..e26e5053 --- /dev/null +++ b/packages/sdk/src/btc/types.ts @@ -0,0 +1,73 @@ +import type { PreviousOutput } from '../types/sign'; + +export type BtcPurpose = 44 | 49 | 84; +export type BtcCoinType = 0 | 1; +export type BtcNetwork = 'mainnet' | 'testnet' | 'regtest'; +export type XpubPrefix = 'xpub' | 'ypub' | 'zpub' | 'tpub' | 'upub' | 'vpub'; + +export interface XpubOptions { + purpose: BtcPurpose; + coinType?: BtcCoinType; + account?: number; +} + +export interface XpubsOptions { + purposes: BtcPurpose[]; + coinType?: BtcCoinType; + account?: number; +} + +/** Wallet UTXO with derivation info */ +export interface WalletUtxo { + txid: string; + vout: number; + value: number; // satoshis + confirmations: number; + address: string; + path: number[]; // derivation path + scriptType: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh'; +} + +/** Wallet summary */ +export interface WalletSummary { + balance: number; // satoshis + unconfirmedBalance: number; // satoshis + totalReceived: number; // satoshis + totalSent: number; // satoshis + txCount: number; + utxoCount: number; +} + +/** Full wallet snapshot for transaction building */ +export interface WalletSnapshot { + summary: WalletSummary; + utxos: WalletUtxo[]; + addresses: { + receiving: string[]; + change: string[]; + }; + nextReceiveIndex: number; + nextChangeIndex: number; +} + +/** Input for transaction building */ +export interface TxBuildInput { + utxos: WalletUtxo[]; + recipient: string; + value: number; // satoshis to send + feeRate: number; // sats/vbyte + purpose: BtcPurpose; + coinType?: BtcCoinType; + changeIndex: number; // index for change address +} + +/** Result of transaction building */ +export interface TxBuildResult { + prevOuts: PreviousOutput[]; + recipient: string; + value: number; + fee: number; + changePath: number[]; + changeValue: number; + totalInput: number; +} diff --git a/packages/sdk/src/btc/wallet.ts b/packages/sdk/src/btc/wallet.ts new file mode 100644 index 00000000..d3e5168b --- /dev/null +++ b/packages/sdk/src/btc/wallet.ts @@ -0,0 +1,160 @@ +import type { + WalletSummary, + WalletSnapshot, + WalletUtxo, + BtcPurpose, +} from './types'; +import type { BtcProvider, BlockbookUtxo } from './provider/types'; +import { inferPurpose } from './slip132'; +import { HARDENED_OFFSET } from '../constants'; + +interface WalletOptions { + xpub: string; + purpose?: BtcPurpose; + provider: BtcProvider; +} + +/** + * Parse a BIP32 path string to array format. + */ +function parsePath(pathStr: string): number[] { + return pathStr + .replace('m/', '') + .split('/') + .map((part) => { + const isHardened = part.endsWith("'") || part.endsWith('h'); + const index = parseInt(part.replace(/['h]/g, ''), 10); + return isHardened ? index + HARDENED_OFFSET : index; + }); +} + +/** + * Determine script type from purpose. + */ +function getScriptType( + purpose: BtcPurpose, +): 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' { + switch (purpose) { + case 44: + return 'p2pkh'; + case 49: + return 'p2sh-p2wpkh'; + case 84: + return 'p2wpkh'; + default: + throw new Error(`Unknown purpose: ${purpose}`); + } +} + +/** + * Convert Blockbook UTXO to wallet UTXO with derivation info. + */ +function toWalletUtxo(utxo: BlockbookUtxo, purpose: BtcPurpose): WalletUtxo { + return { + txid: utxo.txid, + vout: utxo.vout, + value: parseInt(utxo.value, 10), + confirmations: utxo.confirmations, + address: utxo.address ?? '', + path: utxo.path ? parsePath(utxo.path) : [], + scriptType: getScriptType(purpose), + }; +} + +/** + * Get wallet summary from an xpub. + * + * @param options - Wallet options + * @returns Wallet summary with balance info + * + * @example + * const summary = await getSummary({ + * xpub: 'zpub...', + * provider: createBlockbookProvider(), + * }); + * console.log(`Balance: ${summary.balance} sats`); + */ +export async function getSummary(options: WalletOptions): Promise { + const { xpub, provider } = options; + + const blockbookSummary = await provider.getSummary(xpub); + const utxos = await provider.getUtxos(xpub); + + return { + balance: parseInt(blockbookSummary.balance, 10), + unconfirmedBalance: parseInt(blockbookSummary.unconfirmedBalance, 10), + totalReceived: parseInt(blockbookSummary.totalReceived, 10), + totalSent: parseInt(blockbookSummary.totalSent, 10), + txCount: blockbookSummary.txs, + utxoCount: utxos.length, + }; +} + +/** + * Get full wallet snapshot for transaction building. + * + * @param options - Wallet options + * @returns Complete wallet snapshot with UTXOs and address info + * + * @example + * const snapshot = await getSnapshot({ + * xpub: 'zpub...', + * provider: createBlockbookProvider(), + * }); + * const txReq = buildTxReq({ utxos: snapshot.utxos, ... }); + */ +export async function getSnapshot( + options: WalletOptions, +): Promise { + const { xpub, provider } = options; + const purpose = options.purpose ?? inferPurpose(xpub); + + const [blockbookSummary, blockbookUtxos] = await Promise.all([ + provider.getSummary(xpub), + provider.getUtxos(xpub), + ]); + + const utxos = blockbookUtxos.map((utxo) => toWalletUtxo(utxo, purpose)); + + const tokens = blockbookSummary.tokens ?? []; + let nextReceiveIndex = 0; + let nextChangeIndex = 0; + const receivingAddresses: string[] = []; + const changeAddresses: string[] = []; + + for (const token of tokens) { + if (token.path) { + const parts = token.path.split('/'); + const isChange = parts[parts.length - 2] === '1'; + const index = parseInt(parts[parts.length - 1], 10); + + if (isChange) { + changeAddresses.push(token.name); + nextChangeIndex = Math.max(nextChangeIndex, index + 1); + } else { + receivingAddresses.push(token.name); + nextReceiveIndex = Math.max(nextReceiveIndex, index + 1); + } + } + } + + const summary: WalletSummary = { + balance: parseInt(blockbookSummary.balance, 10), + unconfirmedBalance: parseInt(blockbookSummary.unconfirmedBalance, 10), + totalReceived: parseInt(blockbookSummary.totalReceived, 10), + totalSent: parseInt(blockbookSummary.totalSent, 10), + txCount: blockbookSummary.txs, + utxoCount: utxos.length, + }; + + return { + summary, + utxos, + addresses: { + receiving: receivingAddresses, + change: changeAddresses, + }, + nextReceiveIndex, + nextChangeIndex, + }; +} diff --git a/packages/sdk/src/btc/xpub.ts b/packages/sdk/src/btc/xpub.ts new file mode 100644 index 00000000..3606bcda --- /dev/null +++ b/packages/sdk/src/btc/xpub.ts @@ -0,0 +1,109 @@ +import { HARDENED_OFFSET } from '../constants'; +import { LatticeGetAddressesFlag } from '../protocol/latticeConstants'; +import { queue } from '../api/utilities'; +import { BTC_PURPOSES, BTC_COIN_TYPES } from './constants'; +import { format } from './slip132'; +import type { BtcPurpose, BtcCoinType, XpubOptions, XpubsOptions } from './types'; + +/** + * Build the derivation path for fetching an xpub. + * Path format: m/purpose'/coinType'/account' + */ +function buildXpubPath( + purpose: BtcPurpose, + coinType: BtcCoinType, + account: number, +): number[] { + return [ + purpose + HARDENED_OFFSET, + coinType + HARDENED_OFFSET, + account + HARDENED_OFFSET, + ]; +} + +/** + * Fetch a single extended public key from the Lattice device. + * + * @param options - Configuration for which xpub to fetch + * @param options.purpose - BIP purpose (44=legacy, 49=wrapped segwit, 84=native segwit) + * @param options.coinType - Coin type (0=mainnet, 1=testnet). Defaults to 0 (mainnet) + * @param options.account - Account index. Defaults to 0 + * @returns Extended public key formatted with appropriate SLIP-132 prefix + * + * @example + * // Fetch native segwit xpub (zpub) for mainnet + * const zpub = await getXpub({ purpose: 84 }); + * + * @example + * // Fetch legacy xpub for testnet account 1 + * const tpub = await getXpub({ purpose: 44, coinType: 1, account: 1 }); + */ +export async function getXpub(options: XpubOptions): Promise { + const { purpose, coinType = BTC_COIN_TYPES.MAINNET, account = 0 } = options; + + const startPath = buildXpubPath(purpose, coinType, account); + const network = coinType === BTC_COIN_TYPES.TESTNET ? 'testnet' : 'mainnet'; + + const result = await queue((client) => + client.getAddresses({ + startPath, + n: 1, + flag: LatticeGetAddressesFlag.secp256k1Xpub, + }), + ); + + if (!result || result.length === 0) { + throw new Error('Failed to fetch xpub from device'); + } + + return format(result[0], purpose, network); +} + +/** + * Fetch multiple extended public keys from the Lattice device. + * + * @param options - Configuration for which xpubs to fetch + * @param options.purposes - Array of BIP purposes to fetch + * @param options.coinType - Coin type (0=mainnet, 1=testnet). Defaults to 0 (mainnet) + * @param options.account - Account index. Defaults to 0 + * @returns Map of purpose to extended public key + * + * @example + * // Fetch all three xpub types for mainnet + * const xpubs = await getXpubs({ purposes: [44, 49, 84] }); + * console.log(xpubs.get(84)); // zpub... + */ +export async function getXpubs( + options: XpubsOptions, +): Promise> { + const { purposes, coinType = BTC_COIN_TYPES.MAINNET, account = 0 } = options; + + const results = new Map(); + + for (const purpose of purposes) { + const xpub = await getXpub({ purpose, coinType, account }); + results.set(purpose, xpub); + } + + return results; +} + +/** + * Convenience function to fetch all standard xpubs (legacy, wrapped, native). + */ +export async function getAllXpubs( + coinType: BtcCoinType = BTC_COIN_TYPES.MAINNET, + account: number = 0, +): Promise<{ xpub: string; ypub: string; zpub: string }> { + const xpubs = await getXpubs({ + purposes: [BTC_PURPOSES.LEGACY, BTC_PURPOSES.WRAPPED, BTC_PURPOSES.NATIVE], + coinType, + account, + }); + + return { + xpub: xpubs.get(BTC_PURPOSES.LEGACY)!, + ypub: xpubs.get(BTC_PURPOSES.WRAPPED)!, + zpub: xpubs.get(BTC_PURPOSES.NATIVE)!, + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8dfc3e0d..53feb6e8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,3 +3,4 @@ export { Client } from './client'; export { EXTERNAL as Constants } from './constants'; export { EXTERNAL as Utils } from './util'; export * from './api'; +export * as btc from './btc'; From e309972391b5193561dfba8b0ea4e1c4f0007031 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:55:35 -0500 Subject: [PATCH 2/5] refactor(btc): extract BTC utilities to @gridplus/btc package Move BTC utilities from packages/sdk/src/btc/ to new packages/btc/ package for independent versioning and reuse. Changes: - Create @gridplus/btc package with slip132, network, tx, wallet utilities - SDK now depends on @gridplus/btc via workspace:* - SDK re-exports @gridplus/btc for backward compatibility - Keep xpub.ts in SDK (has Lattice-specific dependencies) - Add type guards and safeParseInt for robustness - Use Uint8Array instead of Buffer for browser compatibility --- packages/btc/eslint.config.mjs | 107 ++++++++++++++++++ packages/btc/package.json | 50 ++++++++ .../src/__test__/unit}/network.test.ts | 13 ++- .../__test__/unit}/provider/blockbook.test.ts | 6 +- .../src/__test__/unit}/slip132.test.ts | 13 ++- .../btc => btc/src/__test__/unit}/tx.test.ts | 14 +-- .../src/__test__/unit}/wallet.test.ts | 6 +- .../{sdk/src/btc => btc/src}/constants.ts | 3 + packages/btc/src/index.ts | 67 +++++++++++ packages/{sdk/src/btc => btc/src}/network.ts | 10 +- .../src/btc => btc/src}/provider/blockbook.ts | 70 ++++++++++-- .../src/btc => btc/src}/provider/index.ts | 0 .../src/btc => btc/src}/provider/types.ts | 15 +++ packages/{sdk/src/btc => btc/src}/slip132.ts | 6 +- packages/{sdk/src/btc => btc/src}/tx.ts | 8 +- packages/{sdk/src/btc => btc/src}/types.ts | 13 ++- packages/{sdk/src/btc => btc/src}/wallet.ts | 70 +++++++++--- packages/btc/tsconfig.json | 24 ++++ packages/btc/tsup.config.ts | 31 +++++ packages/btc/vitest.config.ts | 7 ++ packages/sdk/package.json | 1 + .../sdk/src/__test__/unit/btc/xpub.test.ts | 2 +- packages/sdk/src/btc/index.ts | 51 ++++++--- packages/sdk/src/btc/xpub.ts | 12 +- pnpm-lock.yaml | 40 +++++++ 25 files changed, 562 insertions(+), 77 deletions(-) create mode 100644 packages/btc/eslint.config.mjs create mode 100644 packages/btc/package.json rename packages/{sdk/src/__test__/unit/btc => btc/src/__test__/unit}/network.test.ts (78%) rename packages/{sdk/src/__test__/unit/btc => btc/src/__test__/unit}/provider/blockbook.test.ts (96%) rename packages/{sdk/src/__test__/unit/btc => btc/src/__test__/unit}/slip132.test.ts (86%) rename packages/{sdk/src/__test__/unit/btc => btc/src/__test__/unit}/tx.test.ts (89%) rename packages/{sdk/src/__test__/unit/btc => btc/src/__test__/unit}/wallet.test.ts (93%) rename packages/{sdk/src/btc => btc/src}/constants.ts (92%) create mode 100644 packages/btc/src/index.ts rename packages/{sdk/src/btc => btc/src}/network.ts (91%) rename packages/{sdk/src/btc => btc/src}/provider/blockbook.ts (63%) rename packages/{sdk/src/btc => btc/src}/provider/index.ts (100%) rename packages/{sdk/src/btc => btc/src}/provider/types.ts (85%) rename packages/{sdk/src/btc => btc/src}/slip132.ts (96%) rename packages/{sdk/src/btc => btc/src}/tx.ts (95%) rename packages/{sdk/src/btc => btc/src}/types.ts (85%) rename packages/{sdk/src/btc => btc/src}/wallet.ts (64%) create mode 100644 packages/btc/tsconfig.json create mode 100644 packages/btc/tsup.config.ts create mode 100644 packages/btc/vitest.config.ts diff --git a/packages/btc/eslint.config.mjs b/packages/btc/eslint.config.mjs new file mode 100644 index 00000000..9c03022e --- /dev/null +++ b/packages/btc/eslint.config.mjs @@ -0,0 +1,107 @@ +import js from '@eslint/js'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import prettierConfig from 'eslint-config-prettier'; +import prettierPlugin from 'eslint-plugin-prettier'; + +const restrictedNodeImports = [ + { name: 'crypto', message: 'Use node:crypto instead.' }, + { name: 'fs', message: 'Use node:fs instead.' }, + { name: 'os', message: 'Use node:os instead.' }, + { name: 'path', message: 'Use node:path instead.' }, + { name: 'stream', message: 'Use node:stream instead.' }, + { name: 'url', message: 'Use node:url instead.' }, + { name: 'util', message: 'Use node:util instead.' }, +]; + +export default [ + js.configs.recommended, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + plugins: { + '@typescript-eslint': tsPlugin, + prettier: prettierPlugin, + }, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: { + Buffer: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + fetch: 'readonly', + Response: 'readonly', + Request: 'readonly', + RequestInit: 'readonly', + AbortController: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + // Test globals + vi: 'readonly', + describe: 'readonly', + it: 'readonly', + test: 'readonly', + expect: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + // Node.js globals + process: 'readonly', + // Browser globals + console: 'readonly', + }, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...prettierPlugin.configs.recommended.rules, + 'prettier/prettier': 'error', + eqeqeq: ['error'], + 'no-var': ['warn'], + 'no-duplicate-imports': ['error'], + 'prefer-const': ['error'], + 'prefer-spread': ['error'], + 'no-console': ['off'], + 'no-redeclare': 'off', + '@typescript-eslint/no-redeclare': 'error', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_' }, + ], + quotes: [ + 'warn', + 'single', + { avoidEscape: true, allowTemplateLiterals: true }, + ], + 'no-restricted-imports': ['error', { paths: restrictedNodeImports }], + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.name='require']", + message: 'Use ESM imports instead of require.', + }, + { + selector: + "AssignmentExpression[left.object.name='module'][left.property.name='exports']", + message: 'Use ESM exports instead of module.exports.', + }, + ], + }, + }, + prettierConfig, + { + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + '*.js', + '*.cjs', + '*.mjs', + 'build/**', + ], + }, +]; diff --git a/packages/btc/package.json b/packages/btc/package.json new file mode 100644 index 00000000..39e2c256 --- /dev/null +++ b/packages/btc/package.json @@ -0,0 +1,50 @@ +{ + "name": "@gridplus/btc", + "version": "0.1.0", + "type": "module", + "description": "BTC utilities for GridPlus SDK - SLIP-132, network inference, transaction building, wallet utilities", + "scripts": { + "build": "tsup", + "test": "vitest run ./src/__test__/unit", + "lint": "eslint src --config eslint.config.mjs", + "lint:fix": "eslint src --config eslint.config.mjs --fix" + }, + "files": [ + "dist" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/GridPlus/gridplus-sdk.git", + "directory": "packages/btc" + }, + "dependencies": { + "bs58check": "^4.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "prettier": "^3.6.2", + "tsup": "^8.5.0", + "typescript": "^5.9.2", + "vitest": "3.2.4" + }, + "license": "MIT", + "engines": { + "node": ">=20" + } +} diff --git a/packages/sdk/src/__test__/unit/btc/network.test.ts b/packages/btc/src/__test__/unit/network.test.ts similarity index 78% rename from packages/sdk/src/__test__/unit/btc/network.test.ts rename to packages/btc/src/__test__/unit/network.test.ts index 5395def7..f90b39d0 100644 --- a/packages/sdk/src/__test__/unit/btc/network.test.ts +++ b/packages/btc/src/__test__/unit/network.test.ts @@ -1,18 +1,21 @@ import bs58check from 'bs58check'; -import { SLIP132_VERSION_BYTES } from '../../../btc/constants'; +import { SLIP132_VERSION_BYTES } from '../../constants'; import { inferFromXpub, getCoinType, getNetworkFromCoinType, isTestnet, -} from '../../../btc/network'; +} from '../../network'; const TEST_XPUB = 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; const toVersion = (xpub: string, version: number) => { - const decoded = Buffer.from(bs58check.decode(xpub)); - decoded.writeUInt32BE(version, 0); - return bs58check.encode(decoded); + const decoded = bs58check.decode(xpub); + const converted = new Uint8Array(decoded.length); + const view = new DataView(converted.buffer); + view.setUint32(0, version, false); + converted.set(decoded.subarray(4), 4); + return bs58check.encode(converted); }; const TEST_TPUB = toVersion(TEST_XPUB, SLIP132_VERSION_BYTES.tpub.public); diff --git a/packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts b/packages/btc/src/__test__/unit/provider/blockbook.test.ts similarity index 96% rename from packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts rename to packages/btc/src/__test__/unit/provider/blockbook.test.ts index 9287aa93..8852c212 100644 --- a/packages/sdk/src/__test__/unit/btc/provider/blockbook.test.ts +++ b/packages/btc/src/__test__/unit/provider/blockbook.test.ts @@ -1,7 +1,7 @@ import { BlockbookProvider, createBlockbookProvider, -} from '../../../../btc/provider/blockbook'; +} from '../../../provider/blockbook'; const buildResponse = (data: unknown, ok = true, status = 200) => ({ ok, @@ -122,7 +122,9 @@ describe('btc/provider/blockbook', () => { it('throws on non-ok responses', async () => { const provider = new BlockbookProvider({ baseUrl: 'https://example.com' }); - fetchMock.mockResolvedValueOnce(buildResponse({ error: 'bad' }, false, 500)); + fetchMock.mockResolvedValueOnce( + buildResponse({ error: 'bad' }, false, 500), + ); await expect(provider.getSummary('xpub123')).rejects.toThrow( 'Blockbook request failed: 500', diff --git a/packages/sdk/src/__test__/unit/btc/slip132.test.ts b/packages/btc/src/__test__/unit/slip132.test.ts similarity index 86% rename from packages/sdk/src/__test__/unit/btc/slip132.test.ts rename to packages/btc/src/__test__/unit/slip132.test.ts index 0a0b4286..351f8756 100644 --- a/packages/sdk/src/__test__/unit/btc/slip132.test.ts +++ b/packages/btc/src/__test__/unit/slip132.test.ts @@ -1,19 +1,22 @@ import bs58check from 'bs58check'; -import { SLIP132_VERSION_BYTES } from '../../../btc/constants'; +import { SLIP132_VERSION_BYTES } from '../../constants'; import { getPrefix, normalize, format, inferPurpose, getVersionBytes, -} from '../../../btc/slip132'; +} from '../../slip132'; const TEST_XPUB = 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; const toVersion = (xpub: string, version: number) => { - const decoded = Buffer.from(bs58check.decode(xpub)); - decoded.writeUInt32BE(version, 0); - return bs58check.encode(decoded); + const decoded = bs58check.decode(xpub); + const converted = new Uint8Array(decoded.length); + const view = new DataView(converted.buffer); + view.setUint32(0, version, false); + converted.set(decoded.subarray(4), 4); + return bs58check.encode(converted); }; const TEST_TPUB = toVersion(TEST_XPUB, SLIP132_VERSION_BYTES.tpub.public); diff --git a/packages/sdk/src/__test__/unit/btc/tx.test.ts b/packages/btc/src/__test__/unit/tx.test.ts similarity index 89% rename from packages/sdk/src/__test__/unit/btc/tx.test.ts rename to packages/btc/src/__test__/unit/tx.test.ts index 6de9cc2a..4e4b8e3e 100644 --- a/packages/sdk/src/__test__/unit/btc/tx.test.ts +++ b/packages/btc/src/__test__/unit/tx.test.ts @@ -1,6 +1,6 @@ -import type { WalletUtxo } from '../../../btc/types'; -import { buildTxReq, estimateFee } from '../../../btc/tx'; -import { HARDENED_OFFSET } from '../../../constants'; +import type { WalletUtxo } from '../../types'; +import { buildTxReq, estimateFee } from '../../tx'; +import { HARDENED_OFFSET } from '../../constants'; const utxos: WalletUtxo[] = [ { @@ -9,13 +9,7 @@ const utxos: WalletUtxo[] = [ value: 100000, confirmations: 6, address: 'bc1qtest', - path: [ - HARDENED_OFFSET + 84, - HARDENED_OFFSET, - HARDENED_OFFSET, - 0, - 0, - ], + path: [HARDENED_OFFSET + 84, HARDENED_OFFSET, HARDENED_OFFSET, 0, 0], scriptType: 'p2wpkh', }, ]; diff --git a/packages/sdk/src/__test__/unit/btc/wallet.test.ts b/packages/btc/src/__test__/unit/wallet.test.ts similarity index 93% rename from packages/sdk/src/__test__/unit/btc/wallet.test.ts rename to packages/btc/src/__test__/unit/wallet.test.ts index 43f8d18c..c7443b10 100644 --- a/packages/sdk/src/__test__/unit/btc/wallet.test.ts +++ b/packages/btc/src/__test__/unit/wallet.test.ts @@ -1,6 +1,6 @@ -import type { BtcProvider } from '../../../btc/provider/types'; -import { getSnapshot, getSummary } from '../../../btc/wallet'; -import { HARDENED_OFFSET } from '../../../constants'; +import type { BtcProvider } from '../../provider/types'; +import { getSnapshot, getSummary } from '../../wallet'; +import { HARDENED_OFFSET } from '../../constants'; const provider: BtcProvider = { getSummary: vi.fn(), diff --git a/packages/sdk/src/btc/constants.ts b/packages/btc/src/constants.ts similarity index 92% rename from packages/sdk/src/btc/constants.ts rename to packages/btc/src/constants.ts index aeab87ee..01ffab78 100644 --- a/packages/sdk/src/btc/constants.ts +++ b/packages/btc/src/constants.ts @@ -1,3 +1,6 @@ +/** BIP32 hardened offset (2^31) */ +export const HARDENED_OFFSET = 0x80000000; + export const SLIP132_VERSION_BYTES = { // Mainnet xpub: { public: 0x0488b21e, private: 0x0488ade4 }, // BIP44 - Legacy (P2PKH) diff --git a/packages/btc/src/index.ts b/packages/btc/src/index.ts new file mode 100644 index 00000000..efb3db9a --- /dev/null +++ b/packages/btc/src/index.ts @@ -0,0 +1,67 @@ +// Constants +export { + HARDENED_OFFSET, + SLIP132_VERSION_BYTES, + BTC_PURPOSES, + BTC_COIN_TYPES, + BTC_NETWORKS, +} from './constants'; + +// Types +export type { + BtcPurpose, + BtcCoinType, + BtcNetwork, + XpubPrefix, + ScriptType, + XpubOptions, + XpubsOptions, + PreviousOutput, + WalletUtxo, + WalletSummary, + WalletSnapshot, + TxBuildInput, + TxBuildResult, +} from './types'; + +// SLIP-132 utilities +export * as slip132 from './slip132'; +export { + getVersionBytes, + getPrefix, + normalize, + format, + inferPurpose, +} from './slip132'; + +// Network utilities +export * as network from './network'; +export { + inferFromXpub, + getCoinType, + getNetworkFromCoinType, + isTestnet, +} from './network'; + +// Transaction building +export { buildTxReq, estimateFee } from './tx'; + +// Wallet utilities +export { getSummary, getSnapshot } from './wallet'; +export type { WalletOptions } from './wallet'; + +// Provider +export * as provider from './provider'; +export { + createBlockbookProvider, + BlockbookProvider, +} from './provider/blockbook'; +export type { + BtcProvider, + BlockbookProviderConfig, + BlockbookUtxo, + BlockbookTransaction, + BlockbookSummary, + FeeRates, + PagingOptions, +} from './provider/types'; diff --git a/packages/sdk/src/btc/network.ts b/packages/btc/src/network.ts similarity index 91% rename from packages/sdk/src/btc/network.ts rename to packages/btc/src/network.ts index f0a33811..77847b40 100644 --- a/packages/sdk/src/btc/network.ts +++ b/packages/btc/src/network.ts @@ -1,4 +1,8 @@ -import { BTC_COIN_TYPES, BTC_NETWORKS, SLIP132_VERSION_BYTES } from './constants'; +import { + BTC_COIN_TYPES, + BTC_NETWORKS, + SLIP132_VERSION_BYTES, +} from './constants'; import type { BtcCoinType, BtcNetwork } from './types'; import { getVersionBytes } from './slip132'; @@ -34,7 +38,9 @@ export function inferFromXpub(xpub: string): BtcNetwork { */ export function getCoinType(network: BtcNetwork): BtcCoinType; export function getCoinType(override: BtcCoinType): BtcCoinType; -export function getCoinType(networkOrOverride: BtcNetwork | BtcCoinType): BtcCoinType { +export function getCoinType( + networkOrOverride: BtcNetwork | BtcCoinType, +): BtcCoinType { if (typeof networkOrOverride === 'number') { return networkOrOverride; } diff --git a/packages/sdk/src/btc/provider/blockbook.ts b/packages/btc/src/provider/blockbook.ts similarity index 63% rename from packages/sdk/src/btc/provider/blockbook.ts rename to packages/btc/src/provider/blockbook.ts index 6c1e168c..6c9f6951 100644 --- a/packages/sdk/src/btc/provider/blockbook.ts +++ b/packages/btc/src/provider/blockbook.ts @@ -4,10 +4,57 @@ import type { BlockbookSummary, BlockbookTransaction, BlockbookUtxo, + BlockbookBroadcastResponse, + BlockbookFeeEstimateResponse, FeeRates, PagingOptions, } from './types'; +/** + * Type guard for xpub response with transactions. + * The response is an object that may contain a transactions array. + */ +function isXpubWithTransactionsResponse( + value: unknown, +): value is { transactions?: BlockbookTransaction[] } { + if (typeof value !== 'object' || value === null) { + return false; + } + const obj = value as Record; + if ('transactions' in obj && obj.transactions !== undefined) { + return Array.isArray(obj.transactions); + } + return true; +} + +/** + * Type guard for BlockbookBroadcastResponse + */ +function isBroadcastResponse( + value: unknown, +): value is BlockbookBroadcastResponse { + return ( + typeof value === 'object' && + value !== null && + 'result' in value && + typeof (value as BlockbookBroadcastResponse).result === 'string' + ); +} + +/** + * Type guard for BlockbookFeeEstimateResponse + */ +function isFeeEstimateResponse( + value: unknown, +): value is BlockbookFeeEstimateResponse { + return ( + typeof value === 'object' && + value !== null && + 'result' in value && + typeof (value as BlockbookFeeEstimateResponse).result === 'string' + ); +} + const DEFAULT_BLOCKBOOK_URLS = { mainnet: 'https://btc1.trezor.io', testnet: 'https://tbtc1.trezor.io', @@ -51,7 +98,10 @@ export class BlockbookProvider implements BtcProvider { }); const response = await this.fetch(`/api/v2/xpub/${xpub}?${params}`); - return (response as any).transactions ?? []; + if (!isXpubWithTransactionsResponse(response)) { + throw new Error('Invalid response from Blockbook xpub endpoint'); + } + return response.transactions ?? []; } /** @@ -71,10 +121,10 @@ export class BlockbookProvider implements BtcProvider { body: rawTx, }); - if (typeof response === 'object' && response && 'result' in response) { - return (response as any).result; + if (isBroadcastResponse(response)) { + return response.result; } - throw new Error('Unexpected broadcast response'); + throw new Error('Unexpected broadcast response format from Blockbook'); } /** @@ -97,14 +147,20 @@ export class BlockbookProvider implements BtcProvider { private async fetchFeeEstimate(blocks: number): Promise { const response = await this.fetch(`/api/v2/estimatefee/${blocks}`); - const btcPerKb = parseFloat((response as any).result); - if (isNaN(btcPerKb) || btcPerKb <= 0) { + if (!isFeeEstimateResponse(response)) { + return 1; + } + const btcPerKb = parseFloat(response.result); + if (Number.isNaN(btcPerKb) || btcPerKb <= 0) { return 1; } return btcPerKb * 100000; } - private async fetch(path: string, options: RequestInit = {}): Promise { + private async fetch( + path: string, + options: RequestInit = {}, + ): Promise { const url = `${this.baseUrl}${path}`; const response = await fetch(url, { ...options, diff --git a/packages/sdk/src/btc/provider/index.ts b/packages/btc/src/provider/index.ts similarity index 100% rename from packages/sdk/src/btc/provider/index.ts rename to packages/btc/src/provider/index.ts diff --git a/packages/sdk/src/btc/provider/types.ts b/packages/btc/src/provider/types.ts similarity index 85% rename from packages/sdk/src/btc/provider/types.ts rename to packages/btc/src/provider/types.ts index 783656e9..85baf044 100644 --- a/packages/sdk/src/btc/provider/types.ts +++ b/packages/btc/src/provider/types.ts @@ -98,3 +98,18 @@ export interface BlockbookProviderConfig { baseUrl?: string; network?: 'mainnet' | 'testnet'; } + +/** Response from xpub endpoint with transaction details */ +export interface BlockbookXpubResponse extends BlockbookSummary { + transactions?: BlockbookTransaction[]; +} + +/** Response from broadcast endpoint */ +export interface BlockbookBroadcastResponse { + result: string; +} + +/** Response from fee estimate endpoint */ +export interface BlockbookFeeEstimateResponse { + result: string; +} diff --git a/packages/sdk/src/btc/slip132.ts b/packages/btc/src/slip132.ts similarity index 96% rename from packages/sdk/src/btc/slip132.ts rename to packages/btc/src/slip132.ts index 856e4eca..5e30456b 100644 --- a/packages/sdk/src/btc/slip132.ts +++ b/packages/btc/src/slip132.ts @@ -7,7 +7,11 @@ import type { BtcNetwork, BtcPurpose, XpubPrefix } from './types'; */ export function getVersionBytes(xpub: string): number { const decoded = bs58check.decode(xpub); - const view = new DataView(decoded.buffer, decoded.byteOffset, decoded.byteLength); + const view = new DataView( + decoded.buffer, + decoded.byteOffset, + decoded.byteLength, + ); return view.getUint32(0, false); // false = big-endian } diff --git a/packages/sdk/src/btc/tx.ts b/packages/btc/src/tx.ts similarity index 95% rename from packages/sdk/src/btc/tx.ts rename to packages/btc/src/tx.ts index 2cb52107..406efbfd 100644 --- a/packages/sdk/src/btc/tx.ts +++ b/packages/btc/src/tx.ts @@ -1,11 +1,11 @@ -import { HARDENED_OFFSET } from '../constants'; -import { BTC_COIN_TYPES } from './constants'; +import { HARDENED_OFFSET, BTC_COIN_TYPES } from './constants'; import type { TxBuildInput, TxBuildResult, WalletUtxo, BtcPurpose, BtcCoinType, + ScriptType, } from './types'; const VBYTE_SIZES = { @@ -28,7 +28,7 @@ const VBYTE_SIZES = { function estimateTxVbytes( inputCount: number, outputCount: number, - inputType: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh', + inputType: ScriptType, ): number { let inputSize: number; switch (inputType) { @@ -183,7 +183,7 @@ export function buildTxReq(input: TxBuildInput): TxBuildResult { export function estimateFee( utxoCount: number, feeRate: number, - inputType: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' = 'p2wpkh', + inputType: ScriptType = 'p2wpkh', ): number { const vbytes = estimateTxVbytes(utxoCount, 2, inputType); return Math.ceil(vbytes * feeRate); diff --git a/packages/sdk/src/btc/types.ts b/packages/btc/src/types.ts similarity index 85% rename from packages/sdk/src/btc/types.ts rename to packages/btc/src/types.ts index e26e5053..c3395451 100644 --- a/packages/sdk/src/btc/types.ts +++ b/packages/btc/src/types.ts @@ -1,9 +1,8 @@ -import type { PreviousOutput } from '../types/sign'; - export type BtcPurpose = 44 | 49 | 84; export type BtcCoinType = 0 | 1; export type BtcNetwork = 'mainnet' | 'testnet' | 'regtest'; export type XpubPrefix = 'xpub' | 'ypub' | 'zpub' | 'tpub' | 'upub' | 'vpub'; +export type ScriptType = 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh'; export interface XpubOptions { purpose: BtcPurpose; @@ -17,6 +16,14 @@ export interface XpubsOptions { account?: number; } +/** UTXO input for transaction signing (compatible with Lattice SDK) */ +export interface PreviousOutput { + txHash: string; + value: number; + index: number; + signerPath: number[]; +} + /** Wallet UTXO with derivation info */ export interface WalletUtxo { txid: string; @@ -25,7 +32,7 @@ export interface WalletUtxo { confirmations: number; address: string; path: number[]; // derivation path - scriptType: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh'; + scriptType: ScriptType; } /** Wallet summary */ diff --git a/packages/sdk/src/btc/wallet.ts b/packages/btc/src/wallet.ts similarity index 64% rename from packages/sdk/src/btc/wallet.ts rename to packages/btc/src/wallet.ts index d3e5168b..ba42d16a 100644 --- a/packages/sdk/src/btc/wallet.ts +++ b/packages/btc/src/wallet.ts @@ -3,12 +3,31 @@ import type { WalletSnapshot, WalletUtxo, BtcPurpose, + ScriptType, } from './types'; import type { BtcProvider, BlockbookUtxo } from './provider/types'; import { inferPurpose } from './slip132'; -import { HARDENED_OFFSET } from '../constants'; +import { HARDENED_OFFSET } from './constants'; -interface WalletOptions { +/** + * Safely parse a string to an integer, throwing a descriptive error if invalid. + * + * @param value - The string value to parse + * @param fieldName - The name of the field for error messages + * @returns The parsed integer + * @throws Error if the value cannot be parsed to a valid integer + */ +function safeParseInt(value: string, fieldName: string): number { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + throw new Error( + `Invalid ${fieldName}: expected numeric string but received "${value}"`, + ); + } + return parsed; +} + +export interface WalletOptions { xpub: string; purpose?: BtcPurpose; provider: BtcProvider; @@ -23,7 +42,8 @@ function parsePath(pathStr: string): number[] { .split('/') .map((part) => { const isHardened = part.endsWith("'") || part.endsWith('h'); - const index = parseInt(part.replace(/['h]/g, ''), 10); + const indexStr = part.replace(/['h]/g, ''); + const index = safeParseInt(indexStr, `path component "${part}"`); return isHardened ? index + HARDENED_OFFSET : index; }); } @@ -31,9 +51,7 @@ function parsePath(pathStr: string): number[] { /** * Determine script type from purpose. */ -function getScriptType( - purpose: BtcPurpose, -): 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh' { +function getScriptType(purpose: BtcPurpose): ScriptType { switch (purpose) { case 44: return 'p2pkh'; @@ -53,7 +71,7 @@ function toWalletUtxo(utxo: BlockbookUtxo, purpose: BtcPurpose): WalletUtxo { return { txid: utxo.txid, vout: utxo.vout, - value: parseInt(utxo.value, 10), + value: safeParseInt(utxo.value, 'utxo.value'), confirmations: utxo.confirmations, address: utxo.address ?? '', path: utxo.path ? parsePath(utxo.path) : [], @@ -74,17 +92,25 @@ function toWalletUtxo(utxo: BlockbookUtxo, purpose: BtcPurpose): WalletUtxo { * }); * console.log(`Balance: ${summary.balance} sats`); */ -export async function getSummary(options: WalletOptions): Promise { +export async function getSummary( + options: WalletOptions, +): Promise { const { xpub, provider } = options; const blockbookSummary = await provider.getSummary(xpub); const utxos = await provider.getUtxos(xpub); return { - balance: parseInt(blockbookSummary.balance, 10), - unconfirmedBalance: parseInt(blockbookSummary.unconfirmedBalance, 10), - totalReceived: parseInt(blockbookSummary.totalReceived, 10), - totalSent: parseInt(blockbookSummary.totalSent, 10), + balance: safeParseInt(blockbookSummary.balance, 'balance'), + unconfirmedBalance: safeParseInt( + blockbookSummary.unconfirmedBalance, + 'unconfirmedBalance', + ), + totalReceived: safeParseInt( + blockbookSummary.totalReceived, + 'totalReceived', + ), + totalSent: safeParseInt(blockbookSummary.totalSent, 'totalSent'), txCount: blockbookSummary.txs, utxoCount: utxos.length, }; @@ -126,7 +152,11 @@ export async function getSnapshot( if (token.path) { const parts = token.path.split('/'); const isChange = parts[parts.length - 2] === '1'; - const index = parseInt(parts[parts.length - 1], 10); + const indexPart = parts[parts.length - 1]; + const index = safeParseInt( + indexPart, + `address index in path "${token.path}"`, + ); if (isChange) { changeAddresses.push(token.name); @@ -139,10 +169,16 @@ export async function getSnapshot( } const summary: WalletSummary = { - balance: parseInt(blockbookSummary.balance, 10), - unconfirmedBalance: parseInt(blockbookSummary.unconfirmedBalance, 10), - totalReceived: parseInt(blockbookSummary.totalReceived, 10), - totalSent: parseInt(blockbookSummary.totalSent, 10), + balance: safeParseInt(blockbookSummary.balance, 'balance'), + unconfirmedBalance: safeParseInt( + blockbookSummary.unconfirmedBalance, + 'unconfirmedBalance', + ), + totalReceived: safeParseInt( + blockbookSummary.totalReceived, + 'totalReceived', + ), + totalSent: safeParseInt(blockbookSummary.totalSent, 'totalSent'), txCount: blockbookSummary.txs, utxoCount: utxos.length, }; diff --git a/packages/btc/tsconfig.json b/packages/btc/tsconfig.json new file mode 100644 index 00000000..c3e95802 --- /dev/null +++ b/packages/btc/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM"], + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "types": ["node", "vitest", "vitest/globals"] + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/btc/tsup.config.ts b/packages/btc/tsup.config.ts new file mode 100644 index 00000000..12a41f8d --- /dev/null +++ b/packages/btc/tsup.config.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'tsup'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse( + readFileSync(resolve(__dirname, 'package.json'), 'utf-8'), +); + +const external = Object.keys({ + ...(pkg.dependencies ?? {}), + ...(pkg.peerDependencies ?? {}), +}); + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: './dist', + format: ['esm', 'cjs'], + target: 'node20', + sourcemap: true, + clean: true, + bundle: true, + dts: true, + silent: true, + outExtension: ({ format }) => ({ + js: format === 'esm' ? '.mjs' : '.cjs', + }), + external, + tsconfig: './tsconfig.json', +}); diff --git a/packages/btc/vitest.config.ts b/packages/btc/vitest.config.ts new file mode 100644 index 00000000..7382f40e --- /dev/null +++ b/packages/btc/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9a6bc8e0..9e0cab57 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -54,6 +54,7 @@ "url": "https://github.com/GridPlus/gridplus-sdk.git" }, "dependencies": { + "@gridplus/btc": "workspace:*", "@ethereumjs/common": "^10.0.0", "@ethereumjs/rlp": "^10.0.0", "@ethereumjs/tx": "^10.0.0", diff --git a/packages/sdk/src/__test__/unit/btc/xpub.test.ts b/packages/sdk/src/__test__/unit/btc/xpub.test.ts index abceaa6e..c3d5168c 100644 --- a/packages/sdk/src/__test__/unit/btc/xpub.test.ts +++ b/packages/sdk/src/__test__/unit/btc/xpub.test.ts @@ -2,7 +2,7 @@ vi.mock('../../../api/utilities', () => ({ queue: vi.fn(), })); -import { BTC_COIN_TYPES, BTC_PURPOSES } from '../../../btc/constants'; +import { BTC_COIN_TYPES, BTC_PURPOSES } from '@gridplus/btc'; import { getAllXpubs, getXpub, getXpubs } from '../../../btc/xpub'; import { queue } from '../../../api/utilities'; diff --git a/packages/sdk/src/btc/index.ts b/packages/sdk/src/btc/index.ts index 4554abb7..c6fce03a 100644 --- a/packages/sdk/src/btc/index.ts +++ b/packages/sdk/src/btc/index.ts @@ -1,36 +1,59 @@ +// Re-export everything from @gridplus/btc export { + // Constants + HARDENED_OFFSET, SLIP132_VERSION_BYTES, BTC_PURPOSES, BTC_COIN_TYPES, BTC_NETWORKS, -} from './constants'; + // SLIP-132 utilities + slip132, + getVersionBytes, + getPrefix, + normalize, + format, + inferPurpose, + // Network utilities + network, + inferFromXpub, + getCoinType, + getNetworkFromCoinType, + isTestnet, + // Transaction building + buildTxReq, + estimateFee, + // Wallet utilities + getSummary, + getSnapshot, + // Provider + provider, + createBlockbookProvider, + BlockbookProvider, +} from '@gridplus/btc'; export type { BtcPurpose, BtcCoinType, BtcNetwork, XpubPrefix, + ScriptType, XpubOptions, XpubsOptions, + PreviousOutput, WalletUtxo, WalletSummary, WalletSnapshot, + WalletOptions, TxBuildInput, TxBuildResult, -} from './types'; - -export * as slip132 from './slip132'; -export * as network from './network'; - -export { getXpub, getXpubs, getAllXpubs } from './xpub'; -export { buildTxReq, estimateFee } from './tx'; -export { getSummary, getSnapshot } from './wallet'; - -export * as provider from './provider'; -export { createBlockbookProvider } from './provider/blockbook'; -export type { BtcProvider, BlockbookProviderConfig, + BlockbookUtxo, + BlockbookTransaction, + BlockbookSummary, FeeRates, PagingOptions, -} from './provider/types'; +} from '@gridplus/btc'; + +// Lattice-specific xpub fetching (requires SDK dependencies) +export { getXpub, getXpubs, getAllXpubs } from './xpub'; diff --git a/packages/sdk/src/btc/xpub.ts b/packages/sdk/src/btc/xpub.ts index 3606bcda..f6068856 100644 --- a/packages/sdk/src/btc/xpub.ts +++ b/packages/sdk/src/btc/xpub.ts @@ -1,9 +1,15 @@ import { HARDENED_OFFSET } from '../constants'; import { LatticeGetAddressesFlag } from '../protocol/latticeConstants'; import { queue } from '../api/utilities'; -import { BTC_PURPOSES, BTC_COIN_TYPES } from './constants'; -import { format } from './slip132'; -import type { BtcPurpose, BtcCoinType, XpubOptions, XpubsOptions } from './types'; +import { + BTC_PURPOSES, + BTC_COIN_TYPES, + format, + type BtcPurpose, + type BtcCoinType, + type XpubOptions, + type XpubsOptions, +} from '@gridplus/btc'; /** * Build the derivation path for fetching an xpub. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba3a4e6..9eac23f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,43 @@ importers: .: {} + packages/btc: + dependencies: + bs58check: + specifier: ^4.0.0 + version: 4.0.0 + devDependencies: + '@eslint/js': + specifier: ^9.36.0 + version: 9.39.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.44.1 + version: 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.44.1 + version: 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: + specifier: ^9.36.0 + version: 9.39.2(jiti@1.21.7) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-prettier: + specifier: ^5.5.4 + version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(prettier@3.7.4) + prettier: + specifier: ^3.6.2 + version: 3.7.4 + tsup: + specifier: ^8.5.0 + version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@24.10.4))(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(jiti@1.21.7)(msw@2.12.7(@types/node@24.10.4)(typescript@5.9.3))(terser@5.44.1)(tsx@4.21.0) + packages/docs: dependencies: '@docusaurus/core': @@ -108,6 +145,9 @@ importers: '@ethereumjs/tx': specifier: ^10.0.0 version: 10.1.0 + '@gridplus/btc': + specifier: workspace:* + version: link:../btc '@metamask/eth-sig-util': specifier: ^8.2.0 version: 8.2.0 From 0054990c98999963a775836e0f1dfb482c3c337a Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:03:21 -0500 Subject: [PATCH 3/5] fix(ci): handle missing simulator directory in cleanup steps The cleanup steps (show logs, stop simulator) used working-directory which fails if the simulator checkout didn't happen. Now checks if directory exists before accessing files. --- .github/workflows/build-test.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index a56cb9df..e8711579 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -114,15 +114,17 @@ jobs: - name: Show simulator logs on failure if: failure() && env.INTERNAL_EVENT == 'true' - working-directory: lattice-simulator run: | - echo "=== Simulator logs ===" - cat simulator.log || echo "No simulator logs found" + if [ -d lattice-simulator ]; then + echo "=== Simulator logs ===" + cat lattice-simulator/simulator.log || echo "No simulator logs found" + else + echo "Simulator directory not found, skipping logs" + fi - name: Stop simulator if: always() && env.INTERNAL_EVENT == 'true' - working-directory: lattice-simulator run: | - if [ -f simulator.pid ]; then - kill $(cat simulator.pid) || true + if [ -f lattice-simulator/simulator.pid ]; then + kill $(cat lattice-simulator/simulator.pid) || true fi From 92fa97ec58af5cb46a57b5522fd769a879bd2512 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:05:46 -0500 Subject: [PATCH 4/5] fix(ci): build @gridplus/btc before SDK in monorepo scripts The SDK depends on @gridplus/btc via workspace:*, so btc must be built first. Updated root package.json scripts to build/test btc package before SDK. --- package.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index d92676ac..dd8ca507 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "gridplus-sdk-monorepo", "private": true, "scripts": { - "build": "pnpm --filter gridplus-sdk run build", - "test": "pnpm --filter gridplus-sdk run test", - "lint": "pnpm --filter gridplus-sdk run lint", - "lint:fix": "pnpm --filter gridplus-sdk run lint:fix", + "build": "pnpm --filter @gridplus/btc run build && pnpm --filter gridplus-sdk run build", + "test": "pnpm --filter @gridplus/btc run build && pnpm --filter @gridplus/btc run test && pnpm --filter gridplus-sdk run test", + "test-unit": "pnpm --filter @gridplus/btc run build && pnpm --filter @gridplus/btc run test && pnpm --filter gridplus-sdk run test-unit", + "lint": "pnpm --filter @gridplus/btc run lint && pnpm --filter gridplus-sdk run lint", + "lint:fix": "pnpm --filter @gridplus/btc run lint:fix && pnpm --filter gridplus-sdk run lint:fix", "e2e": "pnpm --filter gridplus-sdk run e2e", "docs:build": "pnpm --filter gridplus-sdk-docs run build", "docs:start": "pnpm --filter gridplus-sdk-docs run start" From 31ade0dfeec4ad1692be949c1270d1bbf517ae0c Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Tue, 13 Jan 2026 06:20:11 -0500 Subject: [PATCH 5/5] fix(btc): add missing @types/node devDependency --- packages/btc/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/btc/package.json b/packages/btc/package.json index 39e2c256..b7a37b35 100644 --- a/packages/btc/package.json +++ b/packages/btc/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/node": "^24.10.4", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", "eslint": "^9.36.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eac23f4..2e1cc7c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@eslint/js': specifier: ^9.36.0 version: 9.39.2 + '@types/node': + specifier: ^24.10.4 + version: 24.10.4 '@typescript-eslint/eslint-plugin': specifier: ^8.44.1 version: 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)