UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

1,185 lines (1,086 loc) 56.9 kB
import chalk from 'chalk' import fs from 'fs' import path from 'path' import { AtomicBEEF, Beef, CreateActionArgs, CreateActionResult, ListActionsResult, MerklePath, Transaction, TransactionInput, TransactionOutput, WalletActionInput, WalletActionOutput } from '@bsv/sdk' import { _tu, TestWalletNoSetup } from '../../utils/TestUtilsWalletStorage' const noLog = true const logFilePath = path.resolve(__dirname, 'createAction2.test.ts') function sanitizeTestName(testName: string): string { const cleanTestName = testName.replace(/[^a-zA-Z0-9_]/g, '_') return cleanTestName.startsWith('LOG_') ? cleanTestName : `LOG_${cleanTestName}` } describe('createAction2 nosend transactions', () => { jest.setTimeout(99999999) let ctxs: TestWalletNoSetup[] = [] const env = _tu.getEnv('test') const testName = () => expect.getState().currentTestName ?? 'test' beforeEach(async () => { ctxs = [] if (env.runMySQL) { ctxs.push(await _tu.createLegacyWalletMySQLCopy(testName())) } ctxs.push(await _tu.createLegacyWalletSQLiteCopy(testName())) }) afterEach(async () => { for (const { wallet } of ctxs) await wallet.destroy() }) test('1_transaction with single output checked using toLogString', async () => { for (const { wallet } of ctxs) { wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingLabel = 'funding transaction for createAction' const fundingArgs: CreateActionArgs = { outputs: [ { basket: 'funding basket', tags: ['funding transaction output', 'test tag'], satoshis: 3, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding Output' } ], labels: [ fundingLabel, 'this is an extra long test label that should be truncated at 80 chars when it is displayed' ], description: 'Funding transaction', options: { noSend: true, randomizeOutputs: false } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) expect(fundingResult.tx).toBeDefined() const actionsResult = await wallet.listActions({ labels: [fundingLabel], includeInputs: true, includeOutputs: true, includeInputSourceLockingScripts: true, includeInputUnlockingScripts: true, includeOutputLockingScripts: true, includeLabels: true }) const rl1 = toLogString(fundingResult.tx!, actionsResult) expect(rl1.log).toBe(`transactions:3 txid:30bdac0f5c6491f130820517802ff57e20e5a50c08b5c65e6976627fb82ae930 version:1 lockTime:0 sats:-4 status:nosend outgoing:true desc:'Funding transaction' labels:['funding transaction for createaction','this is an extra long test label that should be truncated at 80 chars when it is...'] inputs: 1 0: sourceTXID:a3a8fe7f541c1383ff7b975af49b27284ae720af5f2705d8409baaf519190d26.2 sats:913 lock:(50)76a914f7238871139f4926cbd592a03a737981e558245d88ac unlock:(214)483045022100cfef1f6d781af99a1de14efd6f24f2a14234a26097012f27121eb36f4e330c1d0220... seq:4294967295 outputs: 2 0: sats:3 lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true basket:'funding basket' desc:'Funding Output' tags:['funding transaction output','test tag'] 1: sats:909 lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:1 spendable:true basket:'default'`) } }) test('2_transaction with multiple outputs checked using toLogString', async () => { for (const { wallet } of ctxs) { wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingLabel = 'funding transaction for createAction' const fundingArgs: CreateActionArgs = { outputs: [ { basket: 'funding basket', tags: ['funding transaction for createAction', 'test tag'], satoshis: 5, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding output' }, { basket: 'extra basket', tags: ['extra transaction output', 'extra test tag'], satoshis: 6, lockingScript: '76a914fedcba9876543210fedcba9876543210fedcba88ac', outputDescription: 'Extra Output' } ], labels: [fundingLabel, 'this is the extra label'], description: 'Funding transaction with multiple outputs', options: { noSend: true, randomizeOutputs: false } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) const actionsResult = await wallet.listActions({ labels: [fundingLabel], includeInputs: true, includeOutputs: true, includeInputSourceLockingScripts: true, includeInputUnlockingScripts: true, includeOutputLockingScripts: true, includeLabels: true }) const rl1 = toLogString(fundingResult.tx!, actionsResult) expect(rl1.log).toBe(`transactions:3 txid:b3848f2cabf5887ec679ca60347a29f6ecad425fda738700265c2f9d22c18ab5 version:1 lockTime:0 sats:-12 status:nosend outgoing:true desc:'Funding transaction with multiple outputs' labels:['funding transaction for createaction','this is the extra label'] inputs: 1 0: sourceTXID:a3a8fe7f541c1383ff7b975af49b27284ae720af5f2705d8409baaf519190d26.2 sats:913 lock:(50)76a914f7238871139f4926cbd592a03a737981e558245d88ac unlock:(212)473044022079020cc8ea5ee6b3610806286e41567147d4b4b07d16bc1341311e00ce7647b0022034... seq:4294967295 outputs: 3 0: sats:5 lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true basket:'funding basket' desc:'Funding output' tags:['funding transaction for createaction','test tag'] 1: sats:6 lock:(48)76a914fedcba9876543210fedcba9876543210fedcba88ac index:1 spendable:true basket:'extra basket' desc:'Extra Output' tags:['extra transaction output','extra test tag'] 2: sats:901 lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:2 spendable:true basket:'default'`) } }) test('3_transaction with explicit change check also uses toLogString on the spend', async () => { for (const { wallet } of ctxs) { wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingArgs: CreateActionArgs = { outputs: [ { satoshis: 4, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding output' } ], description: 'Funding transaction', options: { noSend: true, randomizeOutputs: false } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) expect(fundingResult.tx).toBeDefined() expect(fundingResult.noSendChange).toBeDefined() expect(fundingResult.noSendChange!.length).toBe(1) log(`noSendChange returned:${JSON.stringify(fundingResult.noSendChange, null, 2)}`) const outputSatoshis = 2 const estimatedFee = 1 const fundingBeef = Beef.fromBinary(fundingResult.tx!) expect(fundingBeef).toBeDefined() const spendingArgs: CreateActionArgs = { inputs: [ { outpoint: `${fundingResult.txid}.0`, unlockingScript: '47304402207f2e9a', inputDescription: 'desc3' } ], inputBEEF: fundingBeef.toBinary(), outputs: [ { satoshis: outputSatoshis, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'First spending Output for check on change ' } ], labels: ['spending transaction test'], description: 'Explicit check on returned change', options: { knownTxids: [fundingResult.txid!], noSend: true, randomizeOutputs: false, noSendChange: [] } } const spendingResult: CreateActionResult = await wallet.createAction(spendingArgs) expect(spendingResult.tx).toBeDefined() log(`Spending transaction created:${JSON.stringify(spendingResult, null, 2)}`) const spendingActionsResult = await wallet.listActions({ labels: ['spending transaction test'], includeInputs: true, includeOutputs: true, includeInputSourceLockingScripts: true, includeInputUnlockingScripts: true, includeOutputLockingScripts: true, includeLabels: true }) const totalInputSatoshis = spendingActionsResult.actions[0]?.inputs?.reduce( (sum, input) => sum + input.sourceSatoshis, 0 ) const expectedChange = totalInputSatoshis! - outputSatoshis - estimatedFee const outputs = spendingActionsResult.actions[0]?.outputs || [] const changeOutput = outputs.find(output => output.basket === 'default') expect(changeOutput!.satoshis).toBe(expectedChange) const actualFee = totalInputSatoshis! - outputSatoshis - expectedChange expect(actualFee).toBe(estimatedFee) const rl1 = toLogString(spendingResult.tx!, spendingActionsResult) expect(rl1.log).toBe(`transactions:5 txid:afa6713aab0957cf5bb00dee532ad7b895e919a99564ec2016b51cb3d472d87f version:1 lockTime:0 sats:1 status:nosend outgoing:true desc:'Explicit check on returned change' labels:['spending transaction test'] inputs: 2 0: sourceTXID:527ffe88f70d5b7de2b8b5ba9966b9c755e7da4de749d4fcd27140a03145a11d.0 sats:995 lock:(50)76a914ab2b66432503a3681fc5af1502207ca458c8752d88ac unlock:(214)483045022100973a84555fa864e08313bda5c88e1991094db7b8d82586c899276155dabcbc9a0220... seq:4294967295 1: sourceTXID:70afdc54187a1cdb8e35f7d00e5e111cbf5c43c4dc3f1da2cc44479133c75f9e.0 sats:4 desc:'Funding output' lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac unlock:(16)47304402207f2e9a seq:4294967295 outputs: 2 0: sats:2 lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true desc:'First spending Output for check on change ' 1: sats:996 lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:1 spendable:true basket:'default'`) } }) test('4_transaction with custom options knownTxids and returnTXIDOnly false uses toLogString', async () => { for (const { wallet } of ctxs) { wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingOutputSatoshis = 4 const fundingArgs: CreateActionArgs = { outputs: [ { satoshis: fundingOutputSatoshis, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding output' } ], description: 'Funding transaction', options: { noSend: true, randomizeOutputs: false } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) expect(fundingResult.tx).toBeDefined() const spendingArgs: CreateActionArgs = { description: 'Check knownTxids and returnTXIDOnly', outputs: [ { satoshis: 4, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'returnTXIDOnly false test' } ], labels: ['custom options test'], options: { knownTxids: ['tx123', 'tx456'], returnTXIDOnly: false, noSend: true, randomizeOutputs: false } } const spendingResult: CreateActionResult = await wallet.createAction(spendingArgs) expect(spendingArgs.options!.knownTxids).toEqual(expect.arrayContaining(['tx123', 'tx456'])) const spendingActionsResult = await wallet.listActions({ labels: ['custom options test'], includeInputs: true, includeOutputs: true, includeInputSourceLockingScripts: true, includeInputUnlockingScripts: true, includeOutputLockingScripts: true, includeLabels: true }) const rl1 = toLogString(spendingResult.tx!, spendingActionsResult) expect(rl1.log).toBe(`transactions:2 txid:38ded69627603b30bd1f55eb3f88098dbf74f2ef0ff5e3cfe6a34f97ce2db9c2 version:1 lockTime:0 sats:-5 status:nosend outgoing:true desc:'Check knownTxids and returnTXIDOnly' labels:['custom options test'] inputs: 1 0: sourceTXID:527ffe88f70d5b7de2b8b5ba9966b9c755e7da4de749d4fcd27140a03145a11d.0 sats:995 lock:(50)76a914ab2b66432503a3681fc5af1502207ca458c8752d88ac unlock:(212)4730440220113a6f72035a6ddcd6930db7e3f3d5c70486f9aaefb095e6fa3557afa916ec37022054... seq:4294967295 outputs: 2 0: sats:4 lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true desc:'returnTXIDOnly false test' 1: sats:990 lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:1 spendable:true basket:'default'`) } }) test('5_transaction with custom options knownTxids and returnTXIDOnly true', async () => { for (const { wallet } of ctxs) { wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingOutputSatoshis = 4 const fundingArgs: CreateActionArgs = { outputs: [ { satoshis: fundingOutputSatoshis, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding output' } ], description: 'Funding transaction', options: { noSend: true } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) expect(fundingResult.tx).toBeDefined() const spendingArgs: CreateActionArgs = { description: 'Check knownTxids and returnTXIDOnly', outputs: [ { satoshis: 4, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'returnTXIDOnly true test' } ], labels: ['custom options test'], options: { knownTxids: ['tx123', 'tx456'], returnTXIDOnly: true, noSend: true } } const spendingResult: CreateActionResult = await wallet.createAction(spendingArgs) expect(spendingResult.tx).not.toBeDefined() expect(spendingArgs.options!.knownTxids).toEqual(expect.arrayContaining(['tx123', 'tx456'])) } }) test('6_transaction with custom options knownTxids check returned BeefParty txids', async () => { for (const { wallet } of ctxs) { wallet.autoKnownTxids = true wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingOutputSatoshis = 4 const fundingArgs: CreateActionArgs = { outputs: [ { satoshis: fundingOutputSatoshis, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding Output' } ], description: 'Funding transaction', options: { noSend: true } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) expect(fundingResult.tx).toBeDefined() const spendingArgs: CreateActionArgs = { description: 'Check knownTxids txids', outputs: [ { satoshis: 4, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Output for check txids' } ], labels: ['custom options test'], options: { knownTxids: ['tx123', 'tx456'], returnTXIDOnly: true, noSend: true } } const spendingResult: CreateActionResult = await wallet.createAction(spendingArgs) expect(spendingResult).toBeDefined() expect(spendingArgs.options!.knownTxids).toEqual(expect.arrayContaining(['tx123', 'tx456'])) const fundingBeef = Beef.fromBinary(fundingResult.tx!) expect(fundingBeef).toBeDefined() const BeefPartyTxids = fundingBeef.txs.map(tx => tx.txid) const expectedTxids = ['tx123', 'tx456'] if (spendingArgs.options?.knownTxids) { expect(spendingArgs.options!.knownTxids?.sort()).toEqual(expectedTxids.sort()) } } }) test('7_transaction with custom options knownTxids check returned BeefParty txids with additional spend', async () => { for (const { wallet } of ctxs) { wallet.autoKnownTxids = true wallet.randomVals = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9] const fundingOutputSatoshis = 4 const fundingArgs: CreateActionArgs = { outputs: [ { satoshis: fundingOutputSatoshis, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Funding Output' } ], description: 'Funding transaction', options: { noSend: true } } const fundingResult: CreateActionResult = await wallet.createAction(fundingArgs) expect(fundingResult.tx).toBeDefined() const spendingArgs: CreateActionArgs = { description: 'Check knownTxids txids extra', outputs: [ { satoshis: 4, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Output for check txids extra' } ], options: { knownTxids: ['tx123', 'tx456'], returnTXIDOnly: false, noSend: true } } const spendingResult: CreateActionResult = await wallet.createAction(spendingArgs) expect(spendingResult).toBeDefined() expect(spendingArgs.options!.knownTxids).toEqual(expect.arrayContaining(['tx123', 'tx456'])) const fundingBeef = Beef.fromBinary(fundingResult.tx!) expect(fundingBeef).toBeDefined() const partyBeefTxids = fundingBeef.txs.map(tx => tx.txid) const expectedTxids = ['tx123', 'tx456'] expect(spendingArgs.options!.knownTxids?.sort()).toEqual(expectedTxids.sort()) const additionalSpendArgs: CreateActionArgs = { description: 'Extra spend transaction', outputs: [ { satoshis: 4, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Extra spend output' } ], labels: ['extra spend test'], options: { knownTxids: spendingArgs.options!.knownTxids, returnTXIDOnly: true, noSend: true } } const additionalSpendResult: CreateActionResult = await wallet.createAction(additionalSpendArgs) expect(additionalSpendResult).toBeDefined() const finalBeef = Beef.fromBinary(spendingResult.tx!) expect(finalBeef).toBeDefined() const finalPartyBeefTxids = finalBeef.txs.map(tx => tx.txid) const finalExpectedTxids = [...expectedTxids] expect(additionalSpendArgs.options!.knownTxids?.sort()).toEqual(finalExpectedTxids.sort()) } }) /* WIP test('8_no-send transaction with zero satoshis output', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { outputs: [ { satoshis: 0, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Invalid output' } ], description: 'Valid transaction', options: { returnTXIDOnly: false, randomizeOutputs: false, noSend: true } } const result: CreateActionResult = await wallet.createAction(args) expect(result.tx).toBeDefined() expect(result.signableTransaction).toBeUndefined() } }) test('9_no-send transaction without auth (should fail)', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { outputs: [ { satoshis: 5, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Valid output' } ], description: 'Valid transaction', options: { returnTXIDOnly: false, randomizeOutputs: false, noSend: true } } await expect(wallet.createAction(args, undefined)).rejects.toThrow() } }) test('10_no-send transaction with malformed args (invalid destination)', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { outputs: [ { satoshis: 6, lockingScript: 'invalid_script', outputDescription: 'Valid output' } ], description: 'Valid transaction', options: { noSend: true } } await expect(wallet.createAction(args)).rejects.toThrow() } }) test('11_transaction with OP_RETURN', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { outputs: [ { satoshis: 0, lockingScript: '6a0c48656c6c6f20576f726c64', outputDescription: 'OP_RETURN data' } ], description: 'Transaction embedding OP_RETURN data', options: { noSend: true } } const result: CreateActionResult = await wallet.createAction(args) expect(result.tx).toBeDefined() expect(result.signableTransaction).toBeUndefined() } }) test('12_high fee transaction', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { inputs: [ { outpoint: 'tx4.0', unlockingScript: '47304402207f2e9a', inputDescription: 'desc4' } ], outputs: [ { satoshis: 950, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Output D' } ], description: 'Transaction that results in high fees (insufficient change)', options: { noSend: true } } await expect(wallet.createAction(args)).rejects.toThrow( /WERR_INSUFFICIENT_FUNDS/ ) } }) test('13_zero fee transaction', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { inputs: [ { outpoint: 'tx5.0', unlockingScript: '47304402207f2e9a', inputDescription: 'desc5' } ], outputs: [ { satoshis: 500, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Output E' } ], description: 'Zero-fee transaction attempt', options: { noSend: true } } await expect(wallet.createAction(args)).rejects.toThrow( /WERR_INSUFFICIENT_FUNDS/ ) } }) test('14_dust transaction', async () => { for (const { wallet, activeStorage: storage } of ctxs) { const args: CreateActionArgs = { outputs: [ { satoshis: 1, lockingScript: '76a914abcdef0123456789abcdef0123456789abcdef88ac', outputDescription: 'Dust output' } ], description: 'Transaction with dust output', options: { noSend: true } } await expect(wallet.createAction(args)).rejects.toThrow( /WERR_INVALID_PARAMETER/ ) } }) */ }) // Helper functions function getExpectedLog(testName: string, logFilePath: string): { log: string; logColor: string } | null { if (!fs.existsSync(logFilePath)) { return null } const fileContent = fs.readFileSync(logFilePath, 'utf8') const sanitizedTestName = sanitizeTestName(testName) // Use regex to extract the correct log constant const logRegex = new RegExp( `const\\s+${sanitizedTestName}\\s*=\\s*\\{\\s*log:\\s*['\`]([\\s\\S]*?)['\`]\\s*,\\s*logColor:\\s*['\`]([\\s\\S]*?)['\`]\\s*\\}`, 'm' ) const match = fileContent.match(logRegex) if (match) { return { log: match[1], logColor: match[2] } } return null } const normalizeVariableParts = (log: string): string => { return log .replace(/txid:[a-f0-9]{64}/g, 'txid:PLACEHOLDER') // Replace txids .replace(/unlock:\(\d+\)(?:483045022100[a-f0-9]{64}0220|[a-f0-9]+)/g, 'unlock:PLACEHOLDER') .replace(/lock:\(\d+\)76a914[a-f0-9]{40}/g, 'lock:PLACEHOLDER') // Replace locking script .replace(/index:\d+ spendable:/g, 'index:PLACEHOLDER spendable:') // Normalize index .trim() } /** * Appends logs as a constant to a test file. * @param {string} testName - The name of the test. * @param {{ log: string; logColor: string }} rl - The log data. */ function appendLogsAsConst(testName: string, rl: { log: string; logColor: string }) { const normalizedTestName = testName .replace(/[^a-zA-Z0-9_ ]/g, '') .trim() .replace(/\s+/g, '_') const sanitizedTestName = sanitizeTestName(normalizedTestName) const logFilePath = path.resolve(__dirname, 'createAction2.man.test.ts') const logConst = ` // Auto-generated test log - ${new Date().toISOString()} const ${sanitizedTestName} = { log: \`${rl.log}\`, logColor: \`${rl.logColor}\` }; `.trim() fs.appendFileSync(logFilePath, `\n${logConst}\n`, 'utf8') } /** * Truncates a string to a maximum length of 80 characters. * @param {string} s - The string to truncate. * @returns {string} - The truncated string. */ const truncate = (s: string) => (s.length > 80 ? s.slice(0, 80) + '...' : s) /** * Formats an optional field if it has a defined value. * @param {string} fieldName - The name of the field. * @param {any} value - The value of the field. * @returns {string} - The formatted field string. */ const formatOptionalField = (fieldName: string, value: any) => value !== undefined && value !== null && value !== '' ? ` ${fieldName}:${value}` : '' /** * Formats an optional field with quotes if it has a defined value. * @param {string} fieldName - The name of the field. * @param {any} value - The value of the field. * @returns {string} - The formatted field string with quotes. */ const formatOptionalFieldWithQuotes = (fieldName: string, value: any) => value !== undefined && value !== null && value !== '' ? ` ${fieldName}:'${value}'` : '' /** * Formats an optional field with color if it has a defined value. * @param {string} fieldName - The name of the field. * @param {any} value - The value of the field. * @param {(val: string) => string} colorFunc - The function to apply color formatting. * @returns {string} - The formatted field string with color. */ const formatOptionalFieldWithColor = (fieldName: string, value: any, colorFunc: (val: string) => string) => value !== undefined && value !== null && value !== '' ? ` ${chalk.gray(fieldName + ':')}${colorFunc(typeof value === 'string' ? value : String(value))}` : '' /** * Formats metadata if present. * @param {any} metadata - The metadata object. * @returns {string} - The formatted metadata string. */ const formatMetadata = (metadata?: any) => metadata && !isEmptyObject(metadata) ? `metadata:${JSON.stringify(metadata)}` : '' /** * Formats the Merkle path if present. * @param {MerklePath | string} [merklePath] - The Merkle path. * @returns {string} - The formatted Merkle path string. */ const formatMerklePath = (merklePath?: MerklePath | string) => (merklePath ? `merklePath:${String(merklePath)}` : '') const MAX_LOG_LINE_LENGTH = 120 // Define in the test /** * Wraps a log line to a specified max length. * @param {string} text - The text to wrap. * @param {number} indent - The indentation level. * @param {number} [maxLength=120] - The maximum length of a line. * @returns {string} - The wrapped log line. */ const wrapLogLine = (text: string, indent: number, maxLength: number = 120): string => { const words = text.trim().split(' ') let wrappedText = ' '.repeat(indent) let currentLineLength = indent * 2 for (const word of words) { if (currentLineLength + word.length + 1 > maxLength) { wrappedText += '\n' + ' '.repeat(indent) + ' ' + word + ' ' currentLineLength = indent * 2 + word.length + 1 } else { wrappedText += word + ' ' currentLineLength += word.length + 1 } } return wrappedText.trimEnd() } /** * Formats an indented line. * @param {number} indent - The indentation level. * @param {string} content - The content of the line. * @returns {string} - The formatted indented line. */ const formatIndentedLineWithWrap = (indent: number, content: string, maxLength: number = 120) => wrapLogLine(content.trim(), indent, maxLength) /** * Formats a list of wallet action inputs for logging. * @param {WalletActionInput[]} [inputs] - The list of wallet action inputs. * @returns {{ log: string; logColor: string }[]} - An array of formatted log strings and their colorized versions. */ const formatInputs = (inputs: WalletActionInput[]) => inputs && inputs.length > 0 ? inputs .sort((a, b) => a.sourceOutpoint.localeCompare(b.sourceOutpoint)) .map((input, i) => { let line = `${i}: sourceTXID:${input.sourceOutpoint} sats:${input.sourceSatoshis}` let color = `${chalk.gray(`${i}:`)} ${chalk.blue(input.sourceOutpoint)} ${chalk.green(`${input.sourceSatoshis} sats`)}` line += formatOptionalFieldWithQuotes('desc', input.inputDescription) color += formatOptionalFieldWithColor('desc', input.inputDescription, chalk.white) if (input.sourceLockingScript) { line += ` lock:(${input.sourceLockingScript.length})${truncate(input.sourceLockingScript)}` color += ` ${chalk.gray('lock:')}(${input.sourceLockingScript.length})${chalk.cyan(truncate(input.sourceLockingScript))}` } if (input.unlockingScript) { line += ` unlock:(${input.unlockingScript.length})${truncate(input.unlockingScript)}` color += ` ${chalk.gray('unlock:')}(${input.unlockingScript.length})${chalk.cyan(truncate(input.unlockingScript))}` } line += ` seq:${input.sequenceNumber}` color += ` ${chalk.gray('seq:')}${input.sequenceNumber}` return { log: formatIndentedLineWithWrap(2, line), logColor: formatIndentedLineWithWrap(2, color) } }) : [ { log: formatIndentedLineWithWrap(2, 'No inputs'), logColor: formatIndentedLineWithWrap(2, chalk.gray('No inputs')) } ] /** * Formats a list of wallet action outputs for logging. * @param {WalletActionOutput[]} [outputs] - The list of wallet action outputs. * @returns {{ log: string; logColor: string }[]} - An array of formatted log strings and their colorized versions. */ const formatOutputs = (outputs: WalletActionOutput[]) => outputs && outputs.length > 0 ? outputs .sort((a, b) => a.satoshis - b.satoshis) .map((output, i) => { let line = `${i}: sats:${output.satoshis} lock:(${output.lockingScript?.length || ''})${truncate(output.lockingScript!) ?? 'N/A'}` let color = `${chalk.gray(`${i}:`)} ${chalk.green(`${output.satoshis} sats`)} ${chalk.gray('lock:')}(${output.lockingScript?.length || ''})${chalk.cyan(truncate(output.lockingScript!) ?? 'N/A')}` line += formatOptionalField('index', output.outputIndex) color += formatOptionalFieldWithColor('index', output.outputIndex, chalk.white) line += formatOptionalField('spendable', output.spendable) color += formatOptionalFieldWithColor('spendable', output.spendable, chalk.white) line += formatOptionalFieldWithQuotes('custinst', output.customInstructions) color += formatOptionalFieldWithColor('custinst', output.customInstructions, chalk.white) line += formatOptionalFieldWithQuotes('basket', output.basket) color += formatOptionalFieldWithColor('basket', output.basket, chalk.white) line += formatOptionalFieldWithQuotes('desc', output.outputDescription) color += formatOptionalFieldWithColor('desc', output.outputDescription, chalk.white) if (output.tags?.length) { const tagsString = `[${output.tags.map(tag => `'${truncate(tag)}'`).join(',')}]` line += ` tags:${tagsString}` color += ` ${chalk.gray('tags:')}${chalk.white(tagsString)}` } return { log: formatIndentedLineWithWrap(2, line), logColor: formatIndentedLineWithWrap(2, color) } }) : [ { log: formatIndentedLineWithWrap(2, 'No outputs'), logColor: formatIndentedLineWithWrap(2, chalk.gray('No outputs')) } ] /** * Formats a list of labels into a string representation. * @param {string[]} [labels] - The list of labels. * @returns {string} - A formatted string of labels enclosed in brackets. */ const formatLabels = (labels?: string[]) => labels && labels.length > 0 ? `[${labels.map(label => `'${truncate(label)}'`).join(',')}]` : '' /** * Generates a formatted log string from an AtomicBEEF object. * @param {AtomicBEEF} atomicBeef - The AtomicBEEF object containing transaction data. * @param {ListActionsResult} [actionsResult] - The result of listing actions, used for additional transaction metadata. * @param {boolean} [showKey=true] - Whether to display key transaction details. * @returns {Promise<{ log: string; logColor: string }>} - An object containing the formatted log string and a colorized version. */ export function toLogString( atomicBeef: AtomicBEEF, actionsResult?: ListActionsResult, showKey: boolean = true ): { log: string; logColor: string } { const BEEF_V1 = 4022206465 try { const beef = Beef.fromBinary(atomicBeef) beef.version = BEEF_V1 let log = `transactions:${beef.txs.length}` let logColor = chalk.gray(`transactions:${beef.txs.length}`) if (showKey) { logColor += ` ${chalk.gray(`key:`)} (${chalk.blue('txid/outpoint')} ${chalk.cyan('script')} ${chalk.green('sats')})` } const mainTxid = beef.txs.slice(-1)[0].txid const mainTx: Transaction = beef.findAtomicTransaction(mainTxid)! const action = actionsResult?.actions.find(a => a.txid === mainTxid) const labelString = formatLabels(action?.labels) const metadataString = formatMetadata(mainTx.metadata) const merklePathString = formatMerklePath(mainTx.merklePath) log += `\n${formatIndentedLineWithWrap(1, `txid:${mainTxid} version:${mainTx.version} lockTime:${mainTx.lockTime}${formatOptionalField('sats', action?.satoshis)}${formatOptionalField('status', action?.status)}${formatOptionalField('outgoing', action?.isOutgoing)}${formatOptionalFieldWithQuotes('desc', action?.description)}${metadataString}${merklePathString} labels:${labelString}`)}` logColor += `\n${formatIndentedLineWithWrap( 1, [ chalk.blue(mainTxid), ` ${chalk.gray('version:')}${mainTx.version}`, ` ${chalk.gray('lockTime:')}${mainTx.lockTime}`, ` ${chalk.green(`${action?.satoshis} sats`)}`, formatOptionalFieldWithColor('status', action?.status, chalk.white), formatOptionalFieldWithColor('outgoing', action?.isOutgoing, chalk.white), formatOptionalFieldWithColor('desc', action?.description, chalk.white), metadataString ? chalk.gray(metadataString) : '', merklePathString ? chalk.gray(merklePathString) : '', ` ${chalk.gray('labels:')}${chalk.white(labelString)}` ] .filter(Boolean) .join('') )}` log += `\n${formatIndentedLine(1, `inputs: ${action?.inputs?.length ?? 0}`)}` logColor += `\n${formatIndentedLine(1, chalk.gray(`inputs: ${action?.inputs?.length ?? 0}`))}` const sortedInputs = (action?.inputs ?? []).sort((a, b) => a.sourceOutpoint.localeCompare(b.sourceOutpoint)) const formattedInputs = formatInputs(sortedInputs) formattedInputs.forEach(({ log: inputLog, logColor: inputLogColor }) => { log += `\n${formatIndentedLine(2, inputLog)}` logColor += `\n${formatIndentedLine(2, inputLogColor)}` }) log += `\n${formatIndentedLineWithWrap(1, `outputs: ${action?.outputs?.length ?? 0}`)}` logColor += `\n${formatIndentedLineWithWrap(1, chalk.gray(`outputs: ${action?.outputs?.length ?? 0}`))}` const sortedOutputs = action?.outputs?.slice().sort((a, b) => a.satoshis - b.satoshis) const formattedOutputs = formatOutputs(sortedOutputs!) formattedOutputs.forEach(({ log: outputLog, logColor: outputLogColor }) => { log += `\n${formatIndentedLine(2, outputLog)}` logColor += `\n${formatIndentedLine(2, outputLogColor)}` }) return { log, logColor } } catch (error) { return { log: `Error parsing transaction: ${(error as Error).message}`, logColor: chalk.red(`Error parsing transaction: ${(error as Error).message}`) } } } export function createActionResultToTxLogString( createActionResult: CreateActionResult, actionsResult?: ListActionsResult, showKey: boolean = false ): { log: string; logColor: string } { const BEEF_V1 = 4022206465 const beef = Beef.fromBinary(createActionResult?.tx!) beef.version = BEEF_V1 const mainTxid = beef.txs.slice(-1)[0].txid return txToLogString(beef.findAtomicTransaction(mainTxid)!, 0, showKey, actionsResult) } const MAX_RECURSION_DEPTH = 3 /** * Truncates a TXID, replacing the middle 48 characters with '...'. * @param {string} txid - The original transaction ID. * @returns {string} - The truncated TXID. */ const truncateTxid = (txid: string): string => { if (txid.length <= 64) { return txid.slice(0, 8) + '...' + txid.slice(-8) } return txid } /** * Formats a list of transaction outputs for logging. * @param {TransactionOutput[]} [outputs] - The list of transaction outputs. * @param {number} indent - The current indentation level. * @returns {{ log: string; logColor: string }[]} - A formatted log string array. */ const formatTxOutputs = (outputs: TransactionOutput[], indent: number) => outputs && outputs.length > 0 ? outputs .sort((a, b) => a.satoshis! - b.satoshis!) .map((output, i) => { let line = formatIndentedLine( indent + 4, `${i}: lock:(${output.lockingScript.toHex().length || ''})${truncate(output.lockingScript.toHex())}` ) let color = formatIndentedLine( indent + 4, `${chalk.gray(`${i}:`)} ${chalk.gray('lock:')}(${output.lockingScript.toHex().length || ''})${chalk.cyan(truncate(output.lockingScript.toHex()))}` ) if (output.satoshis) { line += ` sats:${output.satoshis}` color += ` ${chalk.green(`${output.satoshis} sats`)}` } return { log: line, logColor: color } }) : [ { log: formatIndentedLine(indent + 4, 'No outputs'), logColor: formatIndentedLine(indent + 4, chalk.gray('No outputs')) } ] /** * Formats transaction inputs with proper indentation. * @param {TransactionInput[]} inputs - The list of transaction inputs. * @param {number} indent - The current indentation level. * @returns {{ log: string; logColor: string }[]} - A formatted log string array. */ const formatTxInputs = (inputs: TransactionInput[], indent: number) => inputs && inputs.length > 0 ? inputs .sort((a, b) => a.sourceTXID!.localeCompare(b.sourceTXID!)) .map((input, i) => { let line = formatIndentedLine( indent + 4, `${i}: sourceTXID:${truncateTxid(input.sourceTXID!)}.${input.sourceOutputIndex}` ) let color = formatIndentedLine( indent + 4, `${chalk.gray(`${i}:`)} ${chalk.blue(truncateTxid(input.sourceTXID!))}.${chalk.blue(input.sourceOutputIndex)}` ) if (input.unlockingScript) { line += `\n${formatIndentedLine(indent + 6, `unlock:(${input.unlockingScript.toHex().length})${truncate(input.unlockingScript.toHex())}`)}` color += `\n${formatIndentedLine(indent + 6, `${chalk.gray('unlock:')}(${input.unlockingScript.toHex().length})${chalk.cyan(truncate(input.unlockingScript.toHex()))}`)}` } if (input.sequence) { line += `\n${formatIndentedLine(indent + 6, `seq:${input.sequence}`)}` color += `\n${formatIndentedLine(indent + 6, `${chalk.gray('seq:')}${input.sequence}`)}` } if (input.sourceTransaction) { const { log: sourceTxLog, logColor: sourceTxLogColor } = txToLogString(input.sourceTransaction, indent + 6) const sourceTxLogTrimed = sourceTxLog.replace(/\s+Transaction/, 'Transaction') const sourceTxLogColorTrimed = sourceTxLogColor.replace(/\s+Transaction/, 'Transaction') line += `\n${formatIndentedLine(indent + 6, `sourceTx:`)}${sourceTxLogTrimed}` color += `\n${formatIndentedLine(indent + 6, `${chalk.gray('sourceTx:')}`)}${sourceTxLogColorTrimed}` } else { line += `\n${formatIndentedLine(indent + 6, `sourceTx:Transaction [Max Depth Reached]`)}` color += `\n${formatIndentedLine(indent + 6, chalk.gray(`sourceTx:Transaction [Max Depth Reached]`))}` } return { log: line, logColor: color } }) : [ { log: formatIndentedLine(indent + 4, 'No inputs'), logColor: formatIndentedLine(indent + 4, chalk.gray('No inputs')) } ] /** * Generates a formatted log string from a Transaction object. * Ensures proper indentation and prevents recursion errors. * @param {Transaction} tx - The Transaction object containing transaction data. * @param {number} indent - The current indentation level. * @param {boolean} [showKey=true] - Whether to display key transaction details. * @param {ListActionsResult} [actionsResult] - The result of listing actions. * @returns {{ log: string; logColor: string }} - A formatted log string and colorized version. */ export function txToLogString( tx: Transaction, indent: number = 0, showKey: boolean = false, actionsResult?: ListActionsResult ): { log: string; logColor: string } { try { if (indent / 2 >= MAX_RECURSION_DEPTH) { return { log: formatIndentedLine(indent + 4, 'Transaction [Max Depth Reached]'), logColor: chalk.gray(formatIndentedLine(indent + 4, 'Transaction [Max Depth Reached]')) } } const beef = Beef.fromBinary(tx.toBEEF()) const mainTxid = beef.txs.slice(-1)[0].txid const metadataString = formatMetadata(tx.metadata) const merklePathString = formatMerklePath(tx.merklePath) let log = formatIndentedLine(indent, `Transaction:${truncateTxid(mainTxid)}`) let logColor = formatIndentedLine(indent, `${chalk.gray('Transaction:')}${chalk.blue(truncateTxid(mainTxid))}`) if (showKey) { logColor += ` ${chalk.gray(`key:`)} (${chalk.blue('txid/outpoint')} ${chalk.cyan('script')} ${chalk.green('sats')})` } log += `\n${formatIndentedLine(indent + 2, `version:${tx.version} lockTime:${tx.lockTime}${metadataString}${merklePathString}`)}` logColor += `\n${formatIndentedLine( indent + 2, `${chalk.gray('version:')}${chalk.white(tx.version)} ${chalk.gray('lockTime:')}${chalk.white(tx.lockTime)}` + (metadataString ? chalk.gray(metadataString) : '') + (merklePathString ? chalk.gray(merklePathString) : '') )}` log += `\n${formatIndentedLine(indent + 2, `inputs: ${tx?.inputs?.length ?? 0}`)}` logColor += `\n${formatIndentedLine(indent + 2, chalk.gray(`inputs: ${tx?.inputs?.length ?? 0}`))}` const sortedInputs = (tx?.inputs ?? []).sort((a, b) => a.sourceTXID!.localeCompare(b.sourceTXID!)) const formattedInputs = formatTxInputs(sortedInputs, indent) formattedInputs.forEach(({ log: inputLog, logColor: inputLogColor }) => { log += `\n${inputLog}` logColor += `\n${inputLogColor}` }) log += `\n${formatIndentedLine(indent + 2, `outputs: ${tx?.outputs?.length ?? 0}`)}` logColor += `\n${formatIndentedLine(indent + 2, chalk.gray(`outputs: ${tx?.outputs?.length ?? 0}`))}` const sortedOutputs = tx?.outputs?.slice().sort((a, b) => a.satoshis! - b.satoshis!) const formattedTxOutputs = formatTxOutputs(sortedOutputs, indent) formattedTxOutputs.forEach(({ log: outputLog, logColor: outputLogColor }) => { log += `\n${outputLog}` logColor += `\n${outputLogColor}` }) return { log, logColor } } catch (error) { return { log: `Error parsing transaction: ${(error as Error).message}`, logColor: chalk.red(`Error parsing transaction: ${(error as Error).message}`) } } } /** * Checks if an object is empty. * @param {unknown} obj - The object to check. * @returns {boolean} - Returns true if the object is empty, otherwise false. */ export const isEmptyObject = (obj: unknown): boolean => { return !!obj && typeof obj === 'object' && Object.keys(obj).length === 0 } const formatIndentedLine = (indent: number, content: string) => ' '.repeat(indent * 2) + content.trim() // Trim ensures no accidental double spacing function log(s: string) { if (!noLog) console.log(s) //if (!noLog) process.stdout.write(s) } function logWarn(s: string) { process.stdout.write(chalk.yellowBright(s)) } export function numberArrayToHexString(numbers: number[]): string { return numbers.map(num => num.toString(16).padStart(2, '0')).join('') } /***Use these to generate the log string ***/ //const testName = expect.getState().currentTestName ?? 'Unknown_Test' //appendLogsAsConst(testName, rl1) // Auto-generated test log - 2025-02-05T13:04:29.906Z const LOG_createAction_nosend_transactions_1_transaction_with_single_output_checked_using_toLogString = { log: `transactions:3 txid:30bdac0f5c6491f130820517802ff57e20e5a50c08b5c65e6976627fb82ae930 version:1 lockTime:0 sats:-4 status:nosend outgoing:true desc:'Funding transaction' labels:['funding transaction for createaction','this is an extra long test label that should be truncated at 80 chars when it is...'] inputs: 1 0: sourceTXID:a3a8fe7f541c1383ff7b975af49b27284ae720af5f2705d8409baaf519190d26.2 sats:913 lock:(50)76a914f7238871139f4926cbd592a03a737981e558245d88ac unlock:(214)483045022100cfef1f6d781af99a1de14efd6f24f2a14234a26097012f27121eb36f4e330c1d0220... seq:4294967295 outputs: 2 0: sats:3 lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true basket:'funding basket' desc:'Funding Output' tags:['funding transaction output','test tag'] 1: sats:909 lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:1 spendable:true basket:'default'`, logColor: `transactions:3 key: (txid/outpoint script sats) 30bdac0f5c6491f130820517802ff57e20e5a50c08b5c65e6976627fb82ae930 version:1 lockTime:0 -4 sats status:nosend outgoing:true desc:Funding transaction labels:['funding transaction for createaction','this is an extra long test label that should be truncated at 80 chars when it is...'] inputs: 1 0: a3a8fe7f541c1383ff7b975af49b27284ae720af5f2705d8409baaf519190d26.2 913 sats lock:(50)76a914f7238871139f4926cbd592a03a737981e558245d88ac unlock:(214)483045022100cfef1f6d781af99a1de14efd6f24f2a14234a26097012f27121eb36f4e330c1d0220... seq:4294967295 outputs: 2 0: 3 sats lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true basket:funding basket desc:Funding Output tags:['funding transaction output','test tag'] 1: 909 sats lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:1 spendable:true basket:default` } // Auto-generated test log - 2025-02-05T13:46:12.091Z const LOG_createAction_nosend_transactions_2_transaction_with_multiple_outputs_checked_using_toLogString = { log: `transactions:3 txid:b3848f2cabf5887ec679ca60347a29f6ecad425fda738700265c2f9d22c18ab5 version:1 lockTime:0 sats:-12 status:nosend outgoing:true desc:'Funding transaction with multiple outputs' labels:['funding transaction for createaction','this is the extra label'] inputs: 1 0: sourceTXID:a3a8fe7f541c1383ff7b975af49b27284ae720af5f2705d8409baaf519190d26.2 sats:913 lock:(50)76a914f7238871139f4926cbd592a03a737981e558245d88ac unlock:(212)473044022079020cc8ea5ee6b3610806286e41567147d4b4b07d16bc1341311e00ce7647b0022034... seq:4294967295 outputs: 3 0: sats:5 lock:(48)76a914abcdef0123456789abcdef0123456789abcdef88ac index:0 spendable:true basket:'funding basket' desc:'Funding output' tags:['funding transaction for createaction','test tag'] 1: sats:6 lock:(48)76a914fedcba9876543210fedcba9876543210fedcba88ac index:1 spendable:true basket:'extra basket' desc:'Extra Output' tags:['extra transaction output','extra test tag'] 2: sats:901 lock:(50)76a9145947e66cdd43c70fb1780116b79e6f7d96e30e0888ac index:2 spendable:true basket:'default'`, logColor: `transactions:3 key: (txid/outpoint script sats) b3848f2cabf5887ec67