// Copyright (c) 2025 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. import * as chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { Ecc, fromHex, shaRmd160, Script, Address, toHex, strToBytes, OP_RETURN, GenesisInfo, SLP_MAX_SEND_OUTPUTS, COINBASE_MATURITY, ALP_POLICY_MAX_OUTPUTS, payment, SLP_TOKEN_TYPE_FUNGIBLE, ALP_TOKEN_TYPE_STANDARD, SLP_TOKEN_TYPE_MINT_VAULT, SLP_NFT1_GROUP, SLP_TOKEN_TYPE_NFT1_GROUP, SLP_TOKEN_TYPE_NFT1_CHILD, ALL_BIP143, MAX_TX_SERSIZE, OP_RETURN_MAX_BYTES, Tx, TxOutput, HdNode, mnemonicToSeed, } from 'ecash-lib'; import { OutPoint, ChronikClient, Token, TokenType, Tx as ChronikTx, TokenStatus, } from 'chronik-client'; import { MockChronikClient } from 'mock-chronik-client'; import { Wallet, CONSOLIDATE_UTXOS_BATCHSIZE, MAX_TX_SERSIZE_ERROR_CODE, MaxTxSersizeError, validateTokenActions, getActionTotals, getTokenType, finalizeOutputs, selectUtxos, SatsSelectionStrategy, paymentOutputsToTxOutputs, getNftChildGenesisInput, getWalletUtxoFromOutput, ChainedTxType, getMaxP2pkhOutputs, removeSpentUtxos, batchTokenSendOutputs, checkTokenSendExceedsMaxOutputs, RequiredTokenInputs, getTokenUtxosWithExactAtoms, WalletUtxo, } from './wallet'; import { WalletBase } from './walletBase'; import { GENESIS_TOKEN_ID_PLACEHOLDER } from 'ecash-lib/dist/payment'; const expect = chai.expect; chai.use(chaiAsPromised); const DUMMY_TIPHEIGHT = 800000; const DUMMY_TIPHASH = '0000000000000000115e051672e3d4a6c523598594825a1194862937941296fe'; const DUMMMY_TXID = '11'.repeat(32); const DUMMY_SK = fromHex('22'.repeat(32)); const testEcc = new Ecc(); const DUMMY_PK = testEcc.derivePubkey(DUMMY_SK); const DUMMY_HASH = shaRmd160(DUMMY_PK); const DUMMY_ADDRESS = Address.p2pkh(DUMMY_HASH).toString(); const DUMMY_SCRIPT = Script.p2pkh(DUMMY_HASH); const DUMMY_OUTPOINT: OutPoint = { txid: DUMMMY_TXID, outIdx: 0, }; const DUMMY_UTXO: WalletUtxo = { outpoint: DUMMY_OUTPOINT, blockHeight: DUMMY_TIPHEIGHT, isCoinbase: false, sats: 546n, isFinal: true, address: DUMMY_ADDRESS, }; /** * Coinbase utxo with blockheight of DUMMY_UTXO, i.e. DUMMY_TIPHEIGHT * Coinbase utxos require COINBASE_MATURITY * confirmations to become spendable */ const DUMMY_UNSPENDABLE_COINBASE_UTXO: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 1 }, isCoinbase: true, sats: 31250000n, }; /** * A coinbase utxo with (just) enough confirmations to be spendable */ const DUMMY_SPENDABLE_COINBASE_UTXO: WalletUtxo = { ...DUMMY_UNSPENDABLE_COINBASE_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 2 }, blockHeight: DUMMY_TIPHEIGHT - COINBASE_MATURITY, }; // Dummy ALP STANDARD utxos (quantity and mintbaton) const DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD = toHex(strToBytes('SLP2')).repeat( 8, ); const ALP_TOKEN_TYPE_STANDARD_ATOMS = 101n; const DUMMY_TOKEN_ALP_TOKEN_TYPE_STANDARD: Token = { tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: ALP_TOKEN_TYPE_STANDARD_ATOMS, isMintBaton: false, }; const DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 3 }, token: DUMMY_TOKEN_ALP_TOKEN_TYPE_STANDARD, }; const DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON: WalletUtxo = { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, outpoint: { ...DUMMY_OUTPOINT, outIdx: 4 }, token: { ...DUMMY_TOKEN_ALP_TOKEN_TYPE_STANDARD, isMintBaton: true, atoms: 0n, }, }; const getDummyAlpUtxo = ( atoms: bigint, tokenId = DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, ) => { return { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_ALP_TOKEN_TYPE_STANDARD, tokenId, atoms }, }; }; // Dummy SLP_TOKEN_TYPE_FUNGIBLE utxos (quantity and mintbaton) const DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE = toHex(strToBytes('SLP0')).repeat( 8, ); const SLP_TOKEN_TYPE_FUNGIBLE_ATOMS = 100n; const DUMMY_TOKEN_SLP_TOKEN_TYPE_FUNGIBLE: Token = { tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, atoms: SLP_TOKEN_TYPE_FUNGIBLE_ATOMS, isMintBaton: false, }; const DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 5 }, token: DUMMY_TOKEN_SLP_TOKEN_TYPE_FUNGIBLE, }; const getDummySlpUtxo = ( atoms: bigint, tokenId = DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, ) => { return { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_SLP_TOKEN_TYPE_FUNGIBLE, tokenId, atoms }, }; }; // Dummy SLP_TOKEN_TYPE_NFT1_GROUP utxos (quantity and mintbaton) const DUMMY_TOKENID_SLP_TOKEN_TYPE_NFT1_GROUP = toHex( strToBytes('NFTP'), ).repeat(8); const SLP_TOKEN_TYPE_NFT1_GROUP_ATOMS = 12n; const DUMMY_TOKEN_SLP_TOKEN_TYPE_NFT1_GROUP: Token = { tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_NFT1_GROUP, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, atoms: SLP_TOKEN_TYPE_NFT1_GROUP_ATOMS, isMintBaton: false, }; const DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 6 }, token: DUMMY_TOKEN_SLP_TOKEN_TYPE_NFT1_GROUP, }; const DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON: WalletUtxo = { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, outpoint: { ...DUMMY_OUTPOINT, outIdx: 7 }, token: { ...DUMMY_TOKEN_SLP_TOKEN_TYPE_NFT1_GROUP, isMintBaton: true, atoms: 0n, }, }; // Dummy SLP_TOKEN_TYPE_MINT_VAULT utxos (quantity only; mint batons do not exist for this type) const DUMMY_TOKENID_SLP_TOKEN_TYPE_MINT_VAULT = toHex( strToBytes('SLP2'), ).repeat(8); const SLP_TOKEN_TYPE_MINT_VAULT_ATOMS = 100n; const DUMMY_TOKEN_SLP_TOKEN_TYPE_MINT_VAULT: Token = { tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_MINT_VAULT, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, atoms: SLP_TOKEN_TYPE_MINT_VAULT_ATOMS, isMintBaton: false, }; const DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 5 }, token: DUMMY_TOKEN_SLP_TOKEN_TYPE_MINT_VAULT, }; const DUMMY_SPENDABLE_COINBASE_UTXO_TOKEN: WalletUtxo = { ...DUMMY_SPENDABLE_COINBASE_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 5 }, token: DUMMY_TOKEN_ALP_TOKEN_TYPE_STANDARD, }; // Utxo set used in testing to show all utxo types supported by ecash-wallet const ALL_SUPPORTED_UTXOS: WalletUtxo[] = [ DUMMY_UTXO, DUMMY_UNSPENDABLE_COINBASE_UTXO, DUMMY_SPENDABLE_COINBASE_UTXO, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON, DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, DUMMY_SPENDABLE_COINBASE_UTXO_TOKEN, ]; const MOCK_DESTINATION_ADDRESS = Address.p2pkh('deadbeef'.repeat(5)).toString(); const MOCK_DESTINATION_SCRIPT = Address.fromCashAddress( MOCK_DESTINATION_ADDRESS, ).toScript(); describe('wallet.ts', () => { it('We can initialize and sync a Wallet', async () => { const mockChronik = new MockChronikClient(); // We can create a wallet const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // We can create a wallet from a mnemonic const mnemonicWallet = Wallet.fromMnemonic( 'morning average minor stable parrot refuse credit exercise february mirror just begin', mockChronik as unknown as ChronikClient, ); const mnemonicSk = mnemonicWallet.sk; // We can generate the same wallet from an sk const mnemonicWalletFromSk = Wallet.fromSk( mnemonicSk, mockChronik as unknown as ChronikClient, ); // They are the same wallet expect(mnemonicWallet.sk).to.deep.equal(mnemonicWalletFromSk.sk); // sk and chronik are directly set by constructor expect(testWallet.sk).to.equal(DUMMY_SK); expect(testWallet.chronik).to.deep.equal(mockChronik); // ecc is initialized automatically expect(testWallet.ecc).to.not.equal(undefined); // pk, hash, script, and address are all derived from sk expect(testWallet.pk).to.deep.equal(DUMMY_PK); expect(testWallet.pkh).to.deep.equal(DUMMY_HASH); expect(testWallet.script).to.deep.equal(DUMMY_SCRIPT); expect(testWallet.address).to.equal(DUMMY_ADDRESS); // tipHeight is zero on creation expect(testWallet.tipHeight).to.equal(0); // utxo set is empty on creation expect(testWallet.utxos).to.deep.equal([]); // We have no spendableSatsOnlyUtxos before sync expect(testWallet.spendableSatsOnlyUtxos()).to.deep.equal([]); // balanceSats returns 0n when wallet has no UTXOs expect(testWallet.balanceSats).to.equal(0n); // Mock a chaintip mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Mock a utxo set mockChronik.setUtxosByAddress( DUMMY_ADDRESS, structuredClone(ALL_SUPPORTED_UTXOS), ); // We can sync the wallet await testWallet.sync(); // Now we have a chaintip expect(testWallet.tipHeight).to.equal(DUMMY_TIPHEIGHT); // We can get spendableSatsOnlyUtxos, which include spendable coinbase utxos expect(testWallet.spendableSatsOnlyUtxos()).to.deep.equal([ DUMMY_UTXO, DUMMY_SPENDABLE_COINBASE_UTXO, ]); // We can get balanceSats (sum of all sats-only UTXOs, including immature coinbase) // Editorial decision: Users want to see sats from immature coinbase utxos // in their balance, even if these are not spendable const expectedBalanceSats = DUMMY_UTXO.sats + DUMMY_SPENDABLE_COINBASE_UTXO.sats + DUMMY_UNSPENDABLE_COINBASE_UTXO.sats; expect(testWallet.balanceSats).to.equal(expectedBalanceSats); // Now we have utxos (all should have address field) expect(testWallet.utxos.length).to.equal(ALL_SUPPORTED_UTXOS.length); testWallet.utxos.forEach((utxo, i) => { expect(utxo).to.deep.equal(ALL_SUPPORTED_UTXOS[i]); }); // We can get the size of a tx without broadcasting it expect( testWallet .clone() .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 546n, }, ], }) .build(ALL_BIP143) .builtTxs[0].size(), ).to.deep.equal(360); // We can calculate max sendable amount const spendableSats = DUMMY_UTXO.sats + DUMMY_SPENDABLE_COINBASE_UTXO.sats; expect(spendableSats).to.equal(312_505_46n); const maxSendSats = testWallet.maxSendSats(); // maxSendSats should be less than total spendable sats (due to fees) expect(maxSendSats).to.equal(312_502_20n); // We can calculate max sendable amount with extra outputs (e.g., OP_RETURN) const opReturnOutput: TxOutput = { sats: 0n, script: new Script(new Uint8Array([OP_RETURN, 0x00, 0x00])), }; const maxSendSatsWithOpReturn = testWallet.maxSendSats([ opReturnOutput, ]); // With OP_RETURN, max sendable should be less (due to larger tx size and higher fee) expect(maxSendSatsWithOpReturn).to.equal(312_502_08n); // We can calculate max sendable amount with custom fee const customFeePerKb = 5000n; // 5000 sats per KB (higher than default 1000) const maxSendSatsCustomFee = testWallet.maxSendSats([], customFeePerKb); // With higher fee, max sendable should be less still expect(maxSendSatsCustomFee).to.equal(312_489_16n); // We can get the fee of a tx without broadcasting it expect( testWallet .clone() .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 546n, }, ], }) .build(ALL_BIP143) .builtTxs[0].fee(), ).to.deep.equal(360n); // We can get the txid of a tx without broadcasting it expect( testWallet .clone() .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 546n, }, ], }) .build(ALL_BIP143).builtTxs[0].txid, ).to.deep.equal( 'c56c1a6606eaa4e46034b3ff452a444395d83afb8bdfbf5b14e81d7657e9003c', ); // Fee can be adjusted by feePerKb param expect( testWallet .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 546n, }, ], feePerKb: 5000n, }) .build() .builtTxs[0].fee(), ).to.deep.equal(1800n); }); it('Can build transaction without XEC change output when noChange is true', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock a chaintip mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Mock a utxo set mockChronik.setUtxosByAddress( DUMMY_ADDRESS, structuredClone(ALL_SUPPORTED_UTXOS), ); // Sync the wallet await testWallet.sync(); // Build a transaction with noChange: true const builtAction = testWallet .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 1000n, }, ], noChange: true, }) .build(ALL_BIP143); const tx = builtAction.builtTxs[0]; // The transaction should have exactly 1 output (no change output) expect(tx.tx.outputs).to.have.length(1); // The single output should be the destination output expect(tx.tx.outputs[0].script.toHex()).to.equal( MOCK_DESTINATION_SCRIPT.toHex(), ); expect(tx.tx.outputs[0].sats).to.equal(1000n); }); it('Can inspect transaction without modifying wallet UTXOs', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock a chaintip mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Mock a utxo set mockChronik.setUtxosByAddress( DUMMY_ADDRESS, structuredClone(ALL_SUPPORTED_UTXOS), ); // Sync the wallet await testWallet.sync(); // Store original UTXO count const originalUtxoCount = testWallet.utxos.length; const originalUtxos = structuredClone(testWallet.utxos); // Inspect a transaction (should not modify UTXOs) const inspectAction = testWallet .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 1000n, }, ], }) .inspect(ALL_BIP143); // Verify inspect() returns InspectAction expect(inspectAction).to.not.equal(undefined); expect(inspectAction.txs).to.be.an('array'); expect(inspectAction.txs.length).to.be.greaterThan(0); // Verify wallet UTXOs were not modified expect(testWallet.utxos.length).to.equal(originalUtxoCount); expect(testWallet.utxos).to.deep.equal(originalUtxos); }); it('InspectAction.fee() returns same fee as build()', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock a chaintip mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Mock a utxo set mockChronik.setUtxosByAddress( DUMMY_ADDRESS, structuredClone(ALL_SUPPORTED_UTXOS), ); // Sync the wallet await testWallet.sync(); const action = testWallet.action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 546n, }, ], }); // Get fee from inspect() const inspectAction = action.inspect(ALL_BIP143); const inspectFee = inspectAction.fee(); // Get fee from build() const builtAction = action.build(ALL_BIP143); const buildFee = builtAction.builtTxs.reduce( (acc, tx) => acc + tx.fee(), 0n, ); // Fees should be the same expect(inspectFee).to.equal(buildFee); }); it('Can build transaction with XEC change output when noChange is false or undefined', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock a chaintip mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Mock a utxo set mockChronik.setUtxosByAddress( DUMMY_ADDRESS, structuredClone(ALL_SUPPORTED_UTXOS), ); // Sync the wallet await testWallet.sync(); // Build a transaction with noChange: false const builtAction = testWallet .action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 1000n, }, ], noChange: false, }) .build(ALL_BIP143); const tx = builtAction.builtTxs[0]; // The transaction should have exactly 2 outputs (destination + change) expect(tx.tx.outputs).to.have.length(2); // The first output should be the destination output expect(tx.tx.outputs[0].script.toHex()).to.equal( MOCK_DESTINATION_SCRIPT.toHex(), ); expect(tx.tx.outputs[0].sats).to.equal(1000n); // The second output should be the change output (wallet's script) expect(tx.tx.outputs[1].script.toHex()).to.equal( testWallet.script.toHex(), ); expect(tx.tx.outputs[1].sats).to.be.greaterThan(0); }); it('Throw error on sync() fail', async () => { const mockChronik = new MockChronikClient(); const errorWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock a chaintip with no error mockChronik.setBlockchainInfo({ tipHash: '0000000000000000115e051672e3d4a6c523598594825a1194862937941296fe', tipHeight: DUMMY_TIPHEIGHT, }); // Mock a chronik error getting utxos mockChronik.setUtxosByAddress( DUMMY_ADDRESS, new Error('some chronik query error'), ); // utxos is empty on creation expect(errorWallet.utxos).to.deep.equal([]); // Throw error if sync wallet and chronik is unavailable await expect(errorWallet.sync()).to.be.rejectedWith( Error, 'some chronik query error', ); // tipHeight will still be zero as we do not set any sync()-related state fields unless we have no errors expect(errorWallet.tipHeight).to.equal(0); // utxos are still empty because there was an error in querying latest utxo set expect(errorWallet.utxos).to.deep.equal([]); }); it('Can build chained SLP burn tx and update the wallet utxo set', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock blockchain info mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Set up UTXOs: we have 45 atoms but need to burn exactly 42 atoms // This will require a chained transaction (send 42 atoms, then burn them) const utxosWithInsufficientExactAtoms = [ { ...DUMMY_UTXO, sats: 50_000n }, // More sats to cover fees for chained txs { ...getDummySlpUtxo(45n), sats: 50_000n }, // We have 45 atoms but need exactly 42, with enough sats for fees ]; mockChronik.setUtxosByAddress( DUMMY_ADDRESS, utxosWithInsufficientExactAtoms, ); await testWallet.sync(); // Store original UTXO state const originalUtxos = structuredClone(testWallet.utxos); // Create a burn action that requires chaining (exact burn of 42 atoms) const burnAction = { outputs: [ { sats: 0n }, // OP_RETURN placeholder ], tokenActions: [ { type: 'BURN', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 42n, // Need exactly 42 atoms }, ] as payment.TokenAction[], }; // Build the chained transaction const builtAction = testWallet.action(burnAction).build(); // Verify we got a chained transaction (2 txs) expect(builtAction.txs).to.have.length(2); // Verify wallet UTXO set was modified expect(testWallet.utxos).not.to.deep.equal(originalUtxos); // Verify both transactions are valid expect(builtAction.builtTxs).to.have.length(2); }); it('Can build chained SLP burn tx without updating wallet utxo set', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Mock blockchain info mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Set up UTXOs: we have 45 atoms but need to burn exactly 42 atoms // This will require a chained transaction (send 42 atoms, then burn them) const utxosWithInsufficientExactAtoms = [ { ...DUMMY_UTXO, sats: 50_000n }, // More sats to cover fees for chained txs { ...getDummySlpUtxo(45n), sats: 50_000n }, // We have 45 atoms but need exactly 42, with enough sats for fees ]; mockChronik.setUtxosByAddress( DUMMY_ADDRESS, utxosWithInsufficientExactAtoms, ); await testWallet.sync(); // Store original UTXO count and state const originalUtxoCount = testWallet.utxos.length; const originalUtxos = structuredClone(testWallet.utxos); // Create a burn action that requires chaining (exact burn of 42 atoms) const burnAction = { outputs: [ { sats: 0n }, // OP_RETURN placeholder ], tokenActions: [ { type: 'BURN', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 42n, // Need exactly 42 atoms }, ] as payment.TokenAction[], }; // Build the chained transaction without updating UTXOs const builtAction = testWallet .clone() .action(burnAction) .build(ALL_BIP143); // Verify we got a chained transaction (2 txs) expect(builtAction.txs).to.have.length(2); // Verify wallet UTXO set was not modified expect(testWallet.utxos).to.have.length(originalUtxoCount); expect(testWallet.utxos).to.deep.equal(originalUtxos); // Verify both transactions are valid expect(builtAction.builtTxs).to.have.length(2); }); context('BuiltAction.broadcast', () => { let mockChronik: MockChronikClient; let testWallet: Wallet; let broadcastTxsCalls: string[][]; let broadcastAndFinalizeTxsCalls: { txs: string[]; timeout: number; }[]; beforeEach(async () => { mockChronik = new MockChronikClient(); broadcastTxsCalls = []; broadcastAndFinalizeTxsCalls = []; const origBroadcastTxs = mockChronik.broadcastTxs.bind(mockChronik); mockChronik.broadcastTxs = async (txsHex, skipTokenChecks) => { broadcastTxsCalls.push(txsHex); return origBroadcastTxs(txsHex, skipTokenChecks); }; const origBroadcastAndFinalizeTxs = mockChronik.broadcastAndFinalizeTxs.bind(mockChronik); mockChronik.broadcastAndFinalizeTxs = async ( txsHex, finalizationTimeoutSecs, skipTokenChecks, ) => { broadcastAndFinalizeTxsCalls.push({ txs: txsHex, timeout: finalizationTimeoutSecs as number, }); return origBroadcastAndFinalizeTxs( txsHex, finalizationTimeoutSecs, skipTokenChecks, ); }; testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); mockChronik.setUtxosByAddress(DUMMY_ADDRESS, [ { ...DUMMY_UTXO, sats: 100_000n }, ]); await testWallet.sync(); }); const buildSendAction = () => testWallet.action({ outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 10_000n, }, ], }); it('uses broadcastTxs by default', async () => { const built = buildSendAction().build(); const hex = toHex(built.txs[0].ser()); mockChronik.setBroadcastTx(hex, 'aa'.repeat(32)); const result = await built.broadcast(); expect(result.success).to.equal(true); expect(broadcastTxsCalls).to.have.length(1); expect(broadcastAndFinalizeTxsCalls).to.have.length(0); }); it('uses broadcastAndFinalizeTxs when finalizationTimeoutSecs is set', async () => { const built = buildSendAction().build(); const hex = toHex(built.txs[0].ser()); mockChronik.setBroadcastTx(hex, 'bb'.repeat(32)); const result = await built.broadcast({ finalizationTimeoutSecs: 45, }); expect(result.success).to.equal(true); expect(broadcastAndFinalizeTxsCalls).to.have.length(1); expect(broadcastTxsCalls).to.have.length(0); expect(broadcastAndFinalizeTxsCalls[0].timeout).to.equal(45); expect(broadcastAndFinalizeTxsCalls[0].txs).to.deep.equal([hex]); }); it('rejects finalizationTimeoutSecs <= 0', async () => { const built = buildSendAction().build(); await expect( built.broadcast({ finalizationTimeoutSecs: 0 }), ).to.be.rejectedWith( Error, 'Use broadcast() without finalizationTimeoutSecs if you do not want to wait for finalization.', ); expect(broadcastTxsCalls).to.have.length(0); expect(broadcastAndFinalizeTxsCalls).to.have.length(0); }); it('returns success false when finalization times out', async () => { const built = buildSendAction().build(); const hex = toHex(built.txs[0].ser()); mockChronik.setBroadcastTx( hex, new Error('504: Transaction(s) failed to finalize within 2s'), ); const result = await built.broadcast({ finalizationTimeoutSecs: 2, retryOnUtxoConflict: false, }); expect(result.success).to.equal(false); expect(result.broadcasted).to.deep.equal([built.builtTxs[0].txid]); expect(result.unbroadcasted).to.deep.equal([]); expect(result.errors?.[0]).to.include('failed to finalize'); expect(broadcastAndFinalizeTxsCalls).to.have.length(1); }); }); }); describe('Support functions', () => { context('validateTokenActions', () => { const dummyGenesisAction = { type: 'GENESIS', tokenType: ALP_TOKEN_TYPE_STANDARD, genesisInfo: { tokenTicker: 'ALP', tokenName: 'ALP Test Token', url: 'cashtab.com', decimals: 0, data: 'deadbeef', }, } as payment.GenesisAction; const tokenOne = '11'.repeat(32); const tokenTwo = '22'.repeat(32); const dummySendActionTokenOne = { type: 'SEND', tokenId: tokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.SendAction; const dummyMintActionTokenOne = { type: 'MINT', tokenId: tokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.MintAction; const dummyBurnActionTokenOne = { type: 'BURN', tokenId: tokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.BurnAction; it('An empty array at the tokenActions key is valid', () => { expect(() => validateTokenActions([])).not.to.throw(); }); it('tokenActions with a genesisAction at index 0 are valid', () => { expect(() => validateTokenActions([dummyGenesisAction]), ).not.to.throw(); }); it('tokenActions that SEND different tokens are valid', () => { expect(() => validateTokenActions([ dummySendActionTokenOne, { ...dummySendActionTokenOne, tokenId: tokenTwo }, ]), ).not.to.throw(); }); it('tokenActions that MINT different tokens are valid', () => { expect(() => validateTokenActions([ dummyMintActionTokenOne, { ...dummyMintActionTokenOne, tokenId: tokenTwo }, ]), ).not.to.throw(); }); it('tokenActions that BURN different tokens are valid', () => { expect(() => validateTokenActions([ dummyBurnActionTokenOne, { ...dummyBurnActionTokenOne, tokenId: tokenTwo }, ]), ).not.to.throw(); }); it('tokenActions that SEND and MINT different tokens are valid', () => { expect(() => validateTokenActions([ dummySendActionTokenOne, { ...dummyMintActionTokenOne, tokenId: tokenTwo }, ]), ).not.to.throw(); }); it('tokenActions that BURN and MINT different tokens are valid', () => { expect(() => validateTokenActions([ dummyBurnActionTokenOne, { ...dummyMintActionTokenOne, tokenId: tokenTwo }, ]), ).not.to.throw(); }); it('We can SEND and BURN the same token', () => { expect(() => validateTokenActions([ dummySendActionTokenOne, dummyBurnActionTokenOne, ]), ).not.to.throw(); }); it('We can MINT and BURN the same token', () => { expect(() => validateTokenActions([ dummyMintActionTokenOne, dummyBurnActionTokenOne, ]), ).not.to.throw(); }); it('tokenActions with a genesisAction at index !==0 are invalid', () => { expect(() => validateTokenActions([ dummySendActionTokenOne, dummyGenesisAction, ]), ).to.throw( Error, `GenesisAction must be at index 0 of tokenActions. Found GenesisAction at index 1.`, ); }); it('tokenActions with duplicate SEND actions are invalid', () => { expect(() => validateTokenActions([ dummySendActionTokenOne, dummySendActionTokenOne, ]), ).to.throw(Error, `Duplicate SEND action for tokenId ${tokenOne}`); }); it('tokenActions with duplicate MINT actions are invalid', () => { expect(() => validateTokenActions([ dummyMintActionTokenOne, dummyMintActionTokenOne, ]), ).to.throw(Error, `Duplicate MINT action for tokenId ${tokenOne}`); }); it('tokenActions with duplicate BURN actions are invalid', () => { expect(() => validateTokenActions([ dummyBurnActionTokenOne, dummyBurnActionTokenOne, ]), ).to.throw(Error, `Duplicate BURN action for tokenId ${tokenOne}`); }); it('tokenActions that call for SEND and MINT of the same tokenId are invalid', () => { expect(() => validateTokenActions([ dummySendActionTokenOne, dummyMintActionTokenOne, ]), ).to.throw( Error, `ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenOne}.`, ); }); it('tokenActions that call for MINT and SEND of the same tokenId are invalid', () => { // We swap the order for this test expect(() => validateTokenActions([ dummyMintActionTokenOne, dummySendActionTokenOne, ]), ).to.throw( Error, `ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenOne}.`, ); }); it('tokenActions that call for MINT of a SLP_TOKEN_TYPE_MINT_VAULT token are invalid', () => { expect(() => validateTokenActions([ { ...dummyMintActionTokenOne, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ]), ).to.throw( Error, `ecash-wallet does not currently support minting SLP_TOKEN_TYPE_MINT_VAULT tokens.`, ); }); it('tokenActions with a genesisAction for SLP_TOKEN_TYPE_NFT1_CHILD are invalid if groupTokenId is not specified', () => { const nftDummyGenesisAction = { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, // No groupTokenId groupTokenId: undefined, } as payment.GenesisAction; expect(() => validateTokenActions([nftDummyGenesisAction]), ).to.throw( Error, `SLP_TOKEN_TYPE_NFT1_CHILD genesis txs must specify a groupTokenId.`, ); }); it('tokenActions with a genesisAction for any other token type are invalid if groupTokenId IS specified', () => { const badAlpGenesisAction = { type: 'GENESIS', tokenType: ALP_TOKEN_TYPE_STANDARD, // groupTokenId wrongly specified groupTokenId: '11'.repeat(32), } as payment.GenesisAction; expect(() => validateTokenActions([badAlpGenesisAction])).to.throw( Error, `ALP_TOKEN_TYPE_STANDARD genesis txs must not specify a groupTokenId.`, ); }); }); context('getActionTotals', () => { it('Returns expected ActionTotal for a non-token action (i.e., sats only)', () => { const action = { outputs: [ { sats: 0n, script: new Script(new Uint8Array([OP_RETURN])), }, { sats: 1000n, script: DUMMY_SCRIPT }, { sats: 1000n, script: DUMMY_SCRIPT }, { sats: 1000n, script: DUMMY_SCRIPT }, ], }; const totals = getActionTotals(action); expect(totals).to.deep.equal({ sats: 3000n, }); }); it('Returns the correct ActionTotal for a single token SEND', () => { const sendTokenId = '11'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // XEC send output { sats: 1000n, script: DUMMY_SCRIPT }, // token SEND output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: sendTokenId, }, ], tokenActions: [ { type: 'SEND' as const, tokenId: sendTokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ], }; const totals = getActionTotals(action); expect(totals).to.deep.equal({ sats: 1546n, tokens: new Map([ [ sendTokenId, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, }, ], ]), }); }); it('Returns the correct ActionTotal for a single token BURN', () => { const burnTokenId = '11'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // XEC send output { sats: 1000n, script: DUMMY_SCRIPT }, // token SEND output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: burnTokenId, }, ], tokenActions: [ { tokenType: ALP_TOKEN_TYPE_STANDARD, type: 'SEND' as const, tokenId: burnTokenId, burnAtoms: 10n, }, { tokenType: ALP_TOKEN_TYPE_STANDARD, type: 'BURN' as const, tokenId: burnTokenId, burnAtoms: 10n, }, ], }; const totals = getActionTotals(action); expect(totals).to.deep.equal({ sats: 1546n, tokens: new Map([ [ burnTokenId, { atoms: 20n, atomsMustBeExact: false, needsMintBaton: false, }, ], ]), }); }); it('Returns the correct ActionTotal for a single token MINT', () => { const mintTokenId = '11'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // XEC send output { sats: 1000n, script: DUMMY_SCRIPT }, // token MINT output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: mintTokenId, isMint: true, }, ], tokenActions: [ { type: 'MINT' as const, tokenId: mintTokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ], }; const totals = getActionTotals(action); expect(totals).to.deep.equal({ sats: 1546n, tokens: new Map([ [ mintTokenId, { atoms: 0n, atomsMustBeExact: false, needsMintBaton: true, }, ], ]), }); }); it('Returns the correct ActionTotal for a GENESIS of an SLP_TOKEN_TYPE_NFT1_CHILD', () => { const groupTokenId = '11'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // SLP_TOKEN_TYPE_NFT1_CHILD GENESIS (aka "NFT mint") { sats: 546n, script: DUMMY_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId, }, ], }; const totals = getActionTotals(action); expect(totals).to.deep.equal({ sats: 546n, groupTokenId, }); }); it('DOES NOT throw for a combined BURN and MINT of a single token', () => { const mintTokenId = '11'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // XEC send output { sats: 1000n, script: DUMMY_SCRIPT }, // token MINT output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: mintTokenId, isMint: true, }, ], tokenActions: [ { type: 'MINT' as const, tokenId: mintTokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'BURN' as const, tokenId: mintTokenId, burnAtoms: 10n, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ], }; expect(() => getActionTotals(action)).not.to.throw(); }); it('Does not throw if one token is burned and a separate token is minted', () => { const mintTokenId = '11'.repeat(32); const burnTokenId = '22'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // XEC send output { sats: 1000n, script: DUMMY_SCRIPT }, // token MINT output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: mintTokenId, isMint: true, }, ], tokenActions: [ { type: 'BURN' as const, tokenId: burnTokenId, burnAtoms: 10n, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT' as const, tokenId: mintTokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ], }; const actionTotals = getActionTotals(action); expect(actionTotals).to.deep.equal({ sats: 1546n, tokens: new Map([ [ mintTokenId, { atoms: 0n, atomsMustBeExact: false, needsMintBaton: true, }, ], [ burnTokenId, { atoms: 10n, atomsMustBeExact: true, needsMintBaton: false, }, ], ]), }); }); it('Returns the correct ActionTotal for sending and burning multiple tokens', () => { const burnTokenId = '11'.repeat(32); const sendTokenId = '22'.repeat(32); const action = { outputs: [ // Blank OP_RETURN { sats: 0n }, // XEC send output { sats: 1000n, script: DUMMY_SCRIPT }, // token 1 SEND output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: burnTokenId, }, // token 2 SEND output { sats: 546n, script: DUMMY_SCRIPT, atoms: 11n, tokenId: sendTokenId, }, // another token 2 SEND output { sats: 546n, script: DUMMY_SCRIPT, atoms: 4n, tokenId: sendTokenId, }, ], tokenActions: [ { type: 'SEND' as const, tokenId: sendTokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND' as const, tokenId: burnTokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'BURN' as const, tokenId: burnTokenId, burnAtoms: 10n, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ], }; const totals = getActionTotals(action); expect(totals).to.deep.equal({ sats: 2638n, tokens: new Map([ [ '11'.repeat(32), { atoms: 20n, atomsMustBeExact: false, needsMintBaton: false, }, ], [ '22'.repeat(32), { atoms: 15n, atomsMustBeExact: false, needsMintBaton: false, }, ], ]), }); }); }); context('selectUtxos', () => { it('Return success false and missing sats for a non-token tx with insufficient sats', () => { const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], }; const spendableUtxos: WalletUtxo[] = []; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 1000n, chainedTxType: ChainedTxType.NONE, errors: [ 'Insufficient sats to complete tx. Need 1000 additional satoshis to complete this Action.', ], config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Return success true for a non-token tx with insufficient sats if NO_SATS strategy', () => { const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], }; const spendableUtxos: WalletUtxo[] = []; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.NO_SATS, }), ).to.deep.equal({ success: true, missingSats: 1000n, utxos: [], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.NO_SATS }, }); }); it('Return success true for a non-token tx with insufficient sats if ATTEMPT_SATS strategy', () => { const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 500n }, { ...DUMMY_UTXO, sats: 300n }, ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS, }), ).to.deep.equal({ success: true, missingSats: 200n, utxos: [ { ...DUMMY_UTXO, sats: 500n }, { ...DUMMY_UTXO, sats: 300n }, ], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS }, }); }); it('Return success true for a non-token tx with sufficient sats if ATTEMPT_SATS strategy', () => { const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 500n }, { ...DUMMY_UTXO, sats: 600n }, ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS, }), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [ { ...DUMMY_UTXO, sats: 500n }, { ...DUMMY_UTXO, sats: 600n }, ], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS }, }); }); it('Return success true for a token tx with sufficient tokens but insufficient sats if ATTEMPT_SATS strategy', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 2n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 500n }, getDummySlpUtxo(2n), ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS, }), ).to.deep.equal({ success: true, missingSats: 500n, utxos: [{ ...DUMMY_UTXO, sats: 500n }, getDummySlpUtxo(2n)], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS }, }); }); it('Return failure for a token tx with missing tokens even if ATTEMPT_SATS strategy', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 2n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(1n), ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS, }), ).to.deep.equal({ success: false, missingSats: 0n, missingTokens: new Map([ [ DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, { atoms: 1n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 1 atom', }, ], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS }, errors: [ `Missing required token utxos: ${DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE} => Missing 1 atom`, ], }); }); it('Return success true for a token tx with sufficient tokens and sats if ATTEMPT_SATS strategy', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 2n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 1_500n }, getDummySlpUtxo(2n), ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS, }), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [{ ...DUMMY_UTXO, sats: 1_500n }, getDummySlpUtxo(2n)], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.ATTEMPT_SATS }, }); }); it('For an XEC-only tx, returns non-token utxos with sufficient sats', () => { const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 750n }, { ...DUMMY_UTXO, sats: 750n }, { ...DUMMY_UTXO, sats: 750n }, ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: [ { ...DUMMY_UTXO, sats: 750n }, { ...DUMMY_UTXO, sats: 750n }, ], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('For an XEC-only tx with more than enough sats in requiredUtxos, returns all requiredUtxos', () => { // NB we must give each utxo unique outpoints so that selectUtxos can properly // remove requiredUtxos from spendable // This is how it would work with "real" utxos which would always have unique txid:outpoint combos const requiredUtxos: OutPoint[] = [ { txid: '1', outIdx: 0 }, { txid: '2', outIdx: 0 }, { txid: '3', outIdx: 0 }, { txid: '4', outIdx: 0 }, { txid: '5', outIdx: 0 }, ]; const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], requiredUtxos, }; // We require all of the utxos const spendableUtxos: WalletUtxo[] = []; for (const outpoint of requiredUtxos) { spendableUtxos.push({ ...DUMMY_UTXO, sats: 750n, outpoint, }); } expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: spendableUtxos, chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Will return when accumulative selection has identified utxos that exactly equal the total output sats', () => { const action = { outputs: [{ sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 1_000n }, { ...DUMMY_UTXO, sats: 750n }, ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: [{ ...DUMMY_UTXO, sats: 1_000n }], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Returns expected object if we have insufficient token utxos', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 2n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(1n), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 0n, missingTokens: new Map([ [ DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, { atoms: 1n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 1 atom', }, ], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, errors: [ `Missing required token utxos: ${DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE} => Missing 1 atom`, ], }); }); it('Returns detailed summary of missing token inputs', () => { const tokenIdToMint = '11'.repeat(32); const tokenToSendAlpha = '22'.repeat(32); const tokenToSendBeta = '33'.repeat(32); const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 100n, tokenId: tokenIdToMint, isMintBaton: false, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 0n, tokenId: tokenIdToMint, isMintBaton: true, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendAlpha, atoms: 20n, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendBeta, atoms: 30n, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenToSendAlpha, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenToSendBeta, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, // no mint baton for token to mint // insufficient utxos for token alpha getDummyAlpUtxo(10n, tokenToSendAlpha), // insufficient utxos for token beta getDummyAlpUtxo(20n, tokenToSendBeta), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 0n, missingTokens: new Map([ [ tokenIdToMint, { atoms: 0n, atomsMustBeExact: false, needsMintBaton: true, error: 'Missing mint baton', }, ], [ tokenToSendAlpha, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 10 atoms', }, ], [ tokenToSendBeta, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 10 atoms', }, ], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, errors: [ `Missing required token utxos: ${tokenIdToMint} => Missing mint baton, ${tokenToSendAlpha} => Missing 10 atoms, ${tokenToSendBeta} => Missing 10 atoms`, ], }); }); it('Returns detailed summary of missing token inputs and missing sats', () => { const tokenIdToMint = '11'.repeat(32); const tokenToSendAlpha = '22'.repeat(32); const tokenToSendBeta = '33'.repeat(32); const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 100n, tokenId: tokenIdToMint, isMintBaton: false, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 0n, tokenId: tokenIdToMint, isMintBaton: true, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendAlpha, atoms: 20n, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendBeta, atoms: 30n, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenToSendAlpha, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenToSendBeta, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 1_000n }, // no mint baton for token to mint // insufficient utxos for token alpha getDummyAlpUtxo(10n, tokenToSendAlpha), // insufficient utxos for token beta getDummyAlpUtxo(20n, tokenToSendBeta), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 1092n, missingTokens: new Map([ [ tokenIdToMint, { atoms: 0n, atomsMustBeExact: false, needsMintBaton: true, error: 'Missing mint baton', }, ], [ tokenToSendAlpha, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 10 atoms', }, ], [ tokenToSendBeta, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 10 atoms', }, ], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, errors: [ `Missing required token utxos: ${tokenIdToMint} => Missing mint baton, ${tokenToSendAlpha} => Missing 10 atoms, ${tokenToSendBeta} => Missing 10 atoms`, ], }); }); it('Returns sufficient token utxos for a single token tx', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(1n), getDummySlpUtxo(1n), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: [{ ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(1n)], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Returns sufficient token utxos for a complicated token tx', () => { const tokenIdToMint = '11'.repeat(32); const tokenToSendAlpha = '22'.repeat(32); const tokenToSendBeta = '33'.repeat(32); const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 100n, tokenId: tokenIdToMint, isMintBaton: false, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 0n, tokenId: tokenIdToMint, isMintBaton: true, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendAlpha, atoms: 20n, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendBeta, atoms: 30n, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenToSendAlpha, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenToSendBeta, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON.token!, tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 0n, isMintBaton: true, }, }, // Sufficient utxos for token alpha getDummyAlpUtxo(1000n, tokenToSendAlpha), // Sufficient utxos for token beta getDummyAlpUtxo(2000n, tokenToSendBeta), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: spendableUtxos, chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Returns sufficient token utxos for a complicated token tx if gasless', () => { const tokenIdToMint = '11'.repeat(32); const tokenToSendAlpha = '22'.repeat(32); const tokenToSendBeta = '33'.repeat(32); const action = { outputs: [ // NB we include no sats only utxos { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 100n, tokenId: tokenIdToMint, isMintBaton: false, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 0n, tokenId: tokenIdToMint, isMintBaton: true, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendAlpha, atoms: 20n, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendBeta, atoms: 30n, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenToSendAlpha, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenToSendBeta, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON.token!, tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 0n, isMintBaton: true, }, }, // Sufficient utxos for token alpha getDummyAlpUtxo(1000n, tokenToSendAlpha), // Sufficient utxos for token beta getDummyAlpUtxo(2000n, tokenToSendBeta), ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.NO_SATS, }), ).to.deep.equal({ success: true, utxos: spendableUtxos, missingSats: 546n, chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.NO_SATS }, }); }); it('Returns detailed summary of missing token inputs for a gasless tx', () => { const tokenIdToMint = '11'.repeat(32); const tokenToSendAlpha = '22'.repeat(32); const tokenToSendBeta = '33'.repeat(32); const action = { outputs: [ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 100n, tokenId: tokenIdToMint, isMintBaton: false, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, isMint: true, atoms: 0n, tokenId: tokenIdToMint, isMintBaton: true, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendAlpha, atoms: 20n, }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: tokenToSendBeta, atoms: 30n, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenToSendAlpha, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenToSendBeta, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: tokenIdToMint, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const spendableUtxos = [ // no mint baton for token to mint // insufficient utxos for token alpha getDummyAlpUtxo(10n, tokenToSendAlpha), // insufficient utxos for token beta getDummyAlpUtxo(20n, tokenToSendBeta), ]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.NO_SATS, }), ).to.deep.equal({ success: false, missingSats: 1092n, missingTokens: new Map([ [ tokenIdToMint, { atoms: 0n, atomsMustBeExact: false, needsMintBaton: true, error: 'Missing mint baton', }, ], [ tokenToSendAlpha, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 10 atoms', }, ], [ tokenToSendBeta, { atoms: 10n, atomsMustBeExact: false, needsMintBaton: false, error: 'Missing 10 atoms', }, ], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.NO_SATS }, errors: [ `Missing required token utxos: 1111111111111111111111111111111111111111111111111111111111111111 => Missing mint baton,` + ` 2222222222222222222222222222222222222222222222222222222222222222 => Missing 10 atoms,` + ` 3333333333333333333333333333333333333333333333333333333333333333 => Missing 10 atoms`, ], }); }); it('Returns expected error for an SLP_TOKEN_TYPE_NFT1_CHILD GENESIS tx if we are missing the SLP_TOKEN_TYPE_NFT1_GROUP input', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const spendableUtxos = [DUMMY_UTXO]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 0n, missingTokens: new Map([ ['11'.repeat(32), { atoms: 1n, needsMintBaton: false }], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, errors: [ `Missing SLP_TOKEN_TYPE_NFT1_GROUP input for groupTokenId 1111111111111111111111111111111111111111111111111111111111111111`, ], }); }); it('Returns expected utxos for an SLP_TOKEN_TYPE_NFT1_CHILD GENESIS tx if we have a qty-1 SLP_TOKEN_TYPE_NFT1_GROUP input', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const mockNftParentInput = { ...DUMMY_UTXO, token: { tokenId: '11'.repeat(32), atoms: 1n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }; const spendableUtxos = [DUMMY_UTXO, mockNftParentInput]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: [mockNftParentInput], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Returns expected utxos for an SLP_TOKEN_TYPE_NFT1_CHILD GENESIS tx if we have a qty-1 SLP_TOKEN_TYPE_NFT1_GROUP input that is exactly specified by the requiredUtxos param', () => { const mockNftParentInput = { ...DUMMY_UTXO, outpoint: { // Unique outpoint so it is picked up as a requiredUtxo txid: 'unique', outIdx: 0, }, token: { tokenId: '11'.repeat(32), atoms: 1n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }; const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, ], requiredUtxos: [mockNftParentInput.outpoint], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const spendableUtxos = [DUMMY_UTXO, mockNftParentInput]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, // NB the parent input is at index 0 utxos: [mockNftParentInput], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Returns utxos with chainedTxType: NFT_MINT_FANOUT for an SLP_TOKEN_TYPE_NFT1_CHILD GENESIS tx if we have a qty >1 SLP_TOKEN_TYPE_NFT1_GROUP input', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const mockNftParentBigInput = { ...DUMMY_UTXO, token: { tokenId: '11'.repeat(32), atoms: 100n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }; const spendableUtxos = [DUMMY_UTXO, mockNftParentBigInput]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, chainedTxType: ChainedTxType.NFT_MINT_FANOUT, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, utxos: [mockNftParentBigInput], }); }); it('Returns expected error for an SLP_TOKEN_TYPE_NFT1_CHILD GENESIS tx if we are missing the SLP_TOKEN_TYPE_NFT1_GROUP input and also sats', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, { sats: 100_000_000n, script: MOCK_DESTINATION_SCRIPT }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const spendableUtxos = [DUMMY_UTXO]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 100_000_000n, missingTokens: new Map([ ['11'.repeat(32), { atoms: 1n, needsMintBaton: false }], ]), chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, errors: [ `Missing SLP_TOKEN_TYPE_NFT1_GROUP input for groupTokenId 1111111111111111111111111111111111111111111111111111111111111111`, ], }); }); it('An NFT mint fails with insufficient sats if we have a qty-1 SLP_TOKEN_TYPE_NFT1_GROUP input but insufficient sats', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, { sats: 100_000_000n, script: MOCK_DESTINATION_SCRIPT }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const mockNftParentInput = { ...DUMMY_UTXO, token: { tokenId: '11'.repeat(32), atoms: 1n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }; const spendableUtxos = [DUMMY_UTXO, mockNftParentInput]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 99999454n, chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, errors: [ 'Insufficient sats to complete tx. Need 99999454 additional satoshis to complete this Action.', ], }); }); it('An NFT mint succeeds with insufficient sats if we have a qty-1 SLP_TOKEN_TYPE_NFT1_GROUP input, insufficient sats, but select a strategy not requiring sats', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, { sats: 100_000_000n, script: MOCK_DESTINATION_SCRIPT }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], }; const mockNftParentInput = { ...DUMMY_UTXO, token: { tokenId: '11'.repeat(32), atoms: 1n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }; const spendableUtxos = [DUMMY_UTXO, mockNftParentInput]; expect( selectUtxos(action, spendableUtxos, { satsStrategy: SatsSelectionStrategy.NO_SATS, }), ).to.deep.equal({ success: true, missingSats: 100000000n, utxos: [mockNftParentInput], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.NO_SATS }, }); }); it('Throws if we specify requiredUtxos for an SLP_TOKEN_TYPE_FUNGIBLE burn tx', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, }, ], requiredUtxos: [getDummySlpUtxo(1000n).outpoint], tokenActions: [ { type: 'BURN', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 42n, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(40n), getDummySlpUtxo(2n), ]; expect(() => selectUtxos(action, spendableUtxos)).to.throw( Error, 'ecash-wallet does not support requiredUtxos for SLP burn txs', ); }); it('Returns utxos with chainedTxType: NONE for an SLP_TOKEN_TYPE_FUNGIBLE burn tx if we have exact atoms', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, }, ], tokenActions: [ { type: 'BURN', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 42n, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(40n), getDummySlpUtxo(2n), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: [ getDummySlpUtxo(40n), getDummySlpUtxo(2n), { ...DUMMY_UTXO, sats: 10_000n }, ], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Returns utxos with chainedTxType: INTENTIONAL_BURN for an SLP_TOKEN_TYPE_FUNGIBLE burn tx if we have enough atoms but not exact atoms', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, }, ], tokenActions: [ { type: 'BURN', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 42n, }, ] as payment.TokenAction[], }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(45n), ]; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: true, missingSats: 0n, utxos: [getDummySlpUtxo(45n), { ...DUMMY_UTXO, sats: 10_000n }], chainedTxType: ChainedTxType.INTENTIONAL_BURN, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS }, }); }); it('Throws when p2shInputData is used with SLP SEND exceeding SLP_MAX_SEND_OUTPUTS', () => { const tooManyOutputs = Array(SLP_MAX_SEND_OUTPUTS + 1) .fill(null) .map(() => ({ sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, isMintBaton: false, })); const action = { outputs: [{ sats: 0n }, ...tooManyOutputs], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], p2shInputData: { lokad: strToBytes('INP1'), data: strToBytes('test'), }, }; const spendableUtxos = [ getDummySlpUtxo(BigInt(SLP_MAX_SEND_OUTPUTS + 1)), { ...DUMMY_UTXO, sats: 100_000n }, ]; expect(() => selectUtxos(action, spendableUtxos)).to.throw( `p2shInputData is not supported when the number of token recipients exceeds ${SLP_MAX_SEND_OUTPUTS} (SLP max per tx). Use p2shInputData only for sends within the protocol limit.`, ); }); it('Throws when p2shInputData is used with XEC outputs exceeding broadcast limit', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); const maxTxSersize = 2000; const maxOutputs = getMaxP2pkhOutputs(1, 0, maxTxSersize); const tooManyOutputs = Array(maxOutputs + 1) .fill(null) .map(() => ({ sats: 546n, script: MOCK_DESTINATION_SCRIPT, })); const largeUtxo = { ...DUMMY_UTXO, sats: BigInt(tooManyOutputs.length) * 546n + 10_000n, }; mockChronik.setUtxosByAddress(DUMMY_ADDRESS, [largeUtxo]); await testWallet.sync(); const action = { outputs: tooManyOutputs, p2shInputData: { lokad: strToBytes('INP1'), data: strToBytes('test'), }, maxTxSersize, }; expect(() => testWallet.action(action).build()).to.throw( 'p2shInputData is not supported when the number of XEC outputs would cause the resulting tx to exceed the broadcast limit. Use p2shInputData only for sends that fit within a single tx.', ); }); it('Throws when p2shInputData is used with NFT mint fan-out', () => { const action = { outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS' as const, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: {}, groupTokenId: '11'.repeat(32), }, ], p2shInputData: { lokad: strToBytes('INP1'), data: strToBytes('test'), }, }; const mockNftParentBigInput = { ...DUMMY_UTXO, token: { tokenId: '11'.repeat(32), atoms: 100n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }; const spendableUtxos = [DUMMY_UTXO, mockNftParentBigInput]; expect(() => selectUtxos(action, spendableUtxos)).to.throw( 'p2shInputData is not supported with NFT mint fan-out. Use p2shInputData only for actions that complete in a single tx.', ); }); it('Throws when p2shInputData is used with intentional SLP burn', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, }, ], tokenActions: [ { type: 'BURN', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 42n, }, ] as payment.TokenAction[], p2shInputData: { lokad: strToBytes('INP1'), data: strToBytes('test'), }, }; const spendableUtxos = [ { ...DUMMY_UTXO, sats: 10_000n }, getDummySlpUtxo(45n), ]; expect(() => selectUtxos(action, spendableUtxos)).to.throw( 'p2shInputData is not supported with intentional SLP burn (requires chained prep tx). Use p2shInputData only for actions that complete in a single tx.', ); }); it('Throws when p2shInputData is used with token tx exceeding broadcast limit', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Use small maxTxSersize so that token outputs (within SLP limit) exceed it. // Must stay within SLP_MAX_SEND_OUTPUTS (19) or we hit that check first. const maxTxSersize = 700; const tokenOutputCount = SLP_MAX_SEND_OUTPUTS - 1; const tokenOutputs = Array(tokenOutputCount) .fill(null) .map(() => ({ sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: 1n, isMintBaton: false, })); const totalAtoms = BigInt(tokenOutputCount); const spendableUtxos = [ getDummySlpUtxo(totalAtoms), { ...DUMMY_UTXO, sats: 100_000n }, ]; mockChronik.setUtxosByAddress(DUMMY_ADDRESS, spendableUtxos); await testWallet.sync(); const action = { outputs: [{ sats: 0n }, ...tokenOutputs], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], p2shInputData: { lokad: strToBytes('INP1'), data: strToBytes('test'), }, maxTxSersize, }; expect(() => testWallet.action(action).build()).to.throw( 'p2shInputData is not supported when the token tx would exceed the broadcast limit. Use p2shInputData only for sends that fit within a single tx.', ); }); it('With ignoredTokenIds, does not select UTXOs for the ignored tokens even if the action requires them', () => { const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, atoms: 100n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; // We have both sats and token UTXOs available const spendableUtxos = [ { ...DUMMY_UTXO, sats: 2_000n }, getDummyAlpUtxo(100n), ]; // With ignoredTokenIds, we should only select the sats UTXO // and not report any error for missing tokens expect( selectUtxos(action, spendableUtxos, { ignoredTokenIds: [DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD], }), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [{ ...DUMMY_UTXO, sats: 2_000n }], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS, ignoredTokenIds: [DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD], }, }); }); it('With ignoredTokenIds, still selects UTXOs for other tokens not being ignored', () => { const OTHER_TOKEN_ID = toHex(strToBytes('OTHR')).repeat(8); const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, // Output requiring the ignored token { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, atoms: 50n, }, // Output requiring a different token { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: OTHER_TOKEN_ID, atoms: 25n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: OTHER_TOKEN_ID, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; // We have sats and both token UTXOs const spendableUtxos = [ { ...DUMMY_UTXO, sats: 3_000n }, getDummyAlpUtxo(50n), // The ignored token getDummyAlpUtxo(25n, OTHER_TOKEN_ID), // The other token ]; // Should select sats and the other token, but not the ignored token // Note: the order depends on when utxos are selected // The OTHER_TOKEN_ID utxo is not matched until iterating through clonedSpendableUtxos // where the sats utxo comes first expect( selectUtxos(action, spendableUtxos, { ignoredTokenIds: [DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD], }), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [ { ...DUMMY_UTXO, sats: 3_000n }, getDummyAlpUtxo(25n, OTHER_TOKEN_ID), ], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS, ignoredTokenIds: [DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD], }, }); }); it('With ignoredTokenIds, can ignore multiple tokenIds at once', () => { const OTHER_TOKEN_ID = toHex(strToBytes('OTHR')).repeat(8); const THIRD_TOKEN_ID = toHex(strToBytes('THRD')).repeat(8); const action = { outputs: [ { sats: 1_000n, script: MOCK_DESTINATION_SCRIPT }, // Output requiring the first ignored token { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, atoms: 50n, }, // Output requiring the second ignored token { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: OTHER_TOKEN_ID, atoms: 25n, }, // Output requiring a non-ignored token { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: THIRD_TOKEN_ID, atoms: 10n, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: OTHER_TOKEN_ID, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: THIRD_TOKEN_ID, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; // We have sats and all three token UTXOs const spendableUtxos = [ { ...DUMMY_UTXO, sats: 3_000n }, getDummyAlpUtxo(50n), // First ignored token getDummyAlpUtxo(25n, OTHER_TOKEN_ID), // Second ignored token getDummyAlpUtxo(10n, THIRD_TOKEN_ID), // Non-ignored token ]; // Should select sats and the non-ignored token, but not the two ignored tokens expect( selectUtxos(action, spendableUtxos, { ignoredTokenIds: [ DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, OTHER_TOKEN_ID, ], }), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [ { ...DUMMY_UTXO, sats: 3_000n }, getDummyAlpUtxo(10n, THIRD_TOKEN_ID), ], chainedTxType: ChainedTxType.NONE, config: { satsStrategy: SatsSelectionStrategy.REQUIRE_SATS, ignoredTokenIds: [ DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, OTHER_TOKEN_ID, ], }, }); }); }); context('getTokenType', () => { it('Returns user-specified genesisAction tokenType for an Action with specified genesisAction', () => { const slpGenesisInfo = { tokenTicker: 'SLP', tokenName: 'SLP Test Token', url: 'cashtab.com', decimals: 0, }; const genesisMintQty = 1_000n; // Construct the Action for this tx const slpGenesisAction: payment.Action = { outputs: [ /** Blank OP_RETURN at outIdx 0 */ { sats: 0n }, /** Mint qty at outIdx 1, per SLP spec */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, atoms: genesisMintQty, }, /** Mint baton at outIdx 2, in valid spec range of range 2-255 */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, atoms: 0n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, genesisInfo: slpGenesisInfo, }, ] as payment.TokenAction[], }; expect(getTokenType(slpGenesisAction)).to.deep.equal({ protocol: 'SLP', type: 'SLP_TOKEN_TYPE_FUNGIBLE', number: 1, }); }); it('Throws if we have multiple token types in tokenActions', () => { const alpGenesisInfo = { tokenTicker: 'ALP', tokenName: 'ALP Test Token', url: 'cashtab.com', decimals: 0, data: 'deadbeef', }; // Construct the Action for this tx const alpGenesisAction: payment.Action = { outputs: [ /** Blank OP_RETURN at outIdx 0 */ { sats: 0n }, /** Mint qty at outIdx 1 */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 100n, }, /** Mint baton at outIdx 2 */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, atoms: 0n, }, /** SLP SEND */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, isMintBaton: false, atoms: 10n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: ALP_TOKEN_TYPE_STANDARD, genesisInfo: alpGenesisInfo, }, { type: 'SEND', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; expect(() => getTokenType(alpGenesisAction)).to.throw( Error, `Action must include only one token type. Found (at least) two: ALP_TOKEN_TYPE_STANDARD and SLP_TOKEN_TYPE_FUNGIBLE.`, ); }); it('Infers and returns token type for a non-genesis tx', () => { const alpMintAction: payment.Action = { outputs: [ /** Blank OP_RETURN at outIdx 0 */ { sats: 0n }, /** Mint qty at outIdx 1 */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, atoms: 10n, }, ], tokenActions: [ { type: 'MINT', tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.MintAction, ], }; expect(getTokenType(alpMintAction)).to.deep.equal( ALP_TOKEN_TYPE_STANDARD, ); }); it('Returns an unsupported token type without throwing', () => { const alpMintAction: payment.Action = { outputs: [ /** Blank OP_RETURN at outIdx 0 */ { sats: 0n }, /** Mint qty at outIdx 1 */ { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_ALP_TOKEN_TYPE_STANDARD, atoms: 10n, }, ], tokenActions: [ { type: 'MINT', tokenType: { ...ALP_TOKEN_TYPE_STANDARD, type: 'SOME_UNSUPPORTED_TYPE', } as unknown as TokenType, } as payment.MintAction, ], }; expect(getTokenType(alpMintAction)).to.deep.equal({ ...ALP_TOKEN_TYPE_STANDARD, type: 'SOME_UNSUPPORTED_TYPE', } as unknown as TokenType); }); }); context('getNftChildGenesisInput', () => { it('Returns exactly 1 input when qty-1 input exists', () => { const tokenId = '11'.repeat(32); const spendableUtxos: WalletUtxo[] = [ { ...DUMMY_UTXO, sats: 1000n }, { ...DUMMY_UTXO, sats: 546n, token: { tokenId, atoms: 1n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, { ...DUMMY_UTXO, sats: 546n, token: { tokenId, atoms: 5n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, ]; const result = getNftChildGenesisInput(tokenId, spendableUtxos); expect(result?.token?.atoms).to.equal(1n); }); it('Returns highest qty input when no qty-1 input exists', () => { const tokenId = '22'.repeat(32); const spendableUtxos: WalletUtxo[] = [ { ...DUMMY_UTXO, sats: 1000n }, { ...DUMMY_UTXO, sats: 546n, token: { tokenId, atoms: 3n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, { ...DUMMY_UTXO, sats: 546n, token: { tokenId, atoms: 7n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, ]; const result = getNftChildGenesisInput(tokenId, spendableUtxos); expect(result?.token?.atoms).to.equal(7n); }); it('Returns undefined when no matching inputs exist', () => { const tokenId = '33'.repeat(32); const spendableUtxos: WalletUtxo[] = [ { ...DUMMY_UTXO, sats: 1000n }, { ...DUMMY_UTXO, sats: 546n, token: { tokenId: 'different'.repeat(32), atoms: 1n, isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, ]; const result = getNftChildGenesisInput(tokenId, spendableUtxos); expect(result).to.equal(undefined); }); it('Prefers qty-1 input over higher quantity inputs', () => { const tokenId = '55'.repeat(32); const spendableUtxos: WalletUtxo[] = [ { ...DUMMY_UTXO, sats: 1000n }, { ...DUMMY_UTXO, token: { tokenId, atoms: 10n, // Higher quantity input isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, { ...DUMMY_UTXO, token: { tokenId, atoms: 1n, // qty-1 input (should be preferred) isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, { ...DUMMY_UTXO, token: { tokenId, atoms: 5n, // Another higher quantity input isMintBaton: false, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, }, ]; const result = getNftChildGenesisInput(tokenId, spendableUtxos); expect(result?.token?.atoms).to.equal(1n); }); }); context('paymentOutputsToTxOutputs', () => { it('Converts PaymentOutput array to TxOutput array with specified sats', () => { const outputs: payment.PaymentOutput[] = [ { sats: 1000n, script: MOCK_DESTINATION_SCRIPT, }, { sats: 2000n, script: DUMMY_SCRIPT, }, ]; const dustSats = 546n; const result = paymentOutputsToTxOutputs(outputs, dustSats); expect(result).to.have.length(2); expect(result[0].sats).to.equal(1000n); expect(result[0].script).to.deep.equal(MOCK_DESTINATION_SCRIPT); expect(result[1].sats).to.equal(2000n); expect(result[1].script).to.deep.equal(DUMMY_SCRIPT); }); it('Uses dustSats as fallback when sats is undefined', () => { const outputs: payment.PaymentOutput[] = [ { script: MOCK_DESTINATION_SCRIPT, }, { script: DUMMY_SCRIPT, }, ]; const dustSats = 546n; const result = paymentOutputsToTxOutputs(outputs, dustSats); expect(result).to.have.length(2); expect(result[0].sats).to.equal(dustSats); expect(result[0].script).to.deep.equal(MOCK_DESTINATION_SCRIPT); expect(result[1].sats).to.equal(dustSats); expect(result[1].script).to.deep.equal(DUMMY_SCRIPT); }); it('Handles mixed sats values correctly', () => { const dummyP2pkhScript = Script.p2pkh(fromHex('00'.repeat(20))); const outputs: payment.PaymentOutput[] = [ { sats: 1000n, script: MOCK_DESTINATION_SCRIPT, }, { // sats undefined, should use dustSats script: DUMMY_SCRIPT, }, { sats: 0n, // Explicit 0 sats (like OP_RETURN) script: dummyP2pkhScript, }, ]; const dustSats = 546n; const result = paymentOutputsToTxOutputs(outputs, dustSats); expect(result).to.have.length(3); expect(result[0].sats).to.equal(1000n); expect(result[0].script).to.deep.equal(MOCK_DESTINATION_SCRIPT); expect(result[1].sats).to.equal(dustSats); expect(result[1].script).to.deep.equal(DUMMY_SCRIPT); expect(result[2].sats).to.equal(0n); expect(result[2].script).to.deep.equal(dummyP2pkhScript); }); it('Works with empty array', () => { const outputs: payment.PaymentOutput[] = []; const dustSats = 546n; const result = paymentOutputsToTxOutputs(outputs, dustSats); expect(result).to.deep.equal([]); }); }); context('finalizeOutputs', () => { /** * NB in practice, "requiredUtxos" will be a calculated param in the class * We will always get the utxos required for the tx based on specified outputs * * So, in these tests, we use dummy values for requiredUtxos, i.e. an ALP token * utxo for ALP token txs, a non-token UTXO for non-token txs, etc * * We need to include requiredUtxos in the finalizeOutputs function because token * actions may need to generate additional outputs that are a function of the inputs, * for example token change which we always assume the user wants to include * UNLESS an intentionalBurn is specified * * We may wish to introduce a "token leftover" output type but I think it's ok to assume * we always want to NOT burn tokens and return any change to the user. */ const nullGenesisInfo: GenesisInfo = { tokenTicker: '\0', tokenName: '\0', url: '\0', data: '00', authPubkey: '00', decimals: 4, }; const DUMMY_CHANGE_SCRIPT = MOCK_DESTINATION_SCRIPT; context('Address support', () => { it('Converts address field to script field for non-token outputs', () => { const testAction = { outputs: [ { address: MOCK_DESTINATION_ADDRESS, sats: 1000n, }, ], }; const result = finalizeOutputs( testAction, // Non-token UTXO [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ); // The function should return the massaged outputs expect(result.txOutputs).to.have.length(1); expect(result.txOutputs[0].sats).to.equal(1000n); expect(result.txOutputs[0].script).to.deep.equal( MOCK_DESTINATION_SCRIPT, ); // Original action should remain unchanged (deep copy behavior) expect(testAction.outputs[0]).to.deep.equal({ address: MOCK_DESTINATION_ADDRESS, sats: 1000n, }); }); it('Leaves script-only outputs unchanged', () => { const testAction = { outputs: [ { script: MOCK_DESTINATION_SCRIPT, sats: 1000n, }, ], }; const result = finalizeOutputs( testAction, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ); // The returned output should have the same script and sats expect(result.txOutputs).to.have.length(1); expect(result.txOutputs[0].sats).to.equal(1000n); expect(result.txOutputs[0].script).to.deep.equal( MOCK_DESTINATION_SCRIPT, ); }); it('Handles multiple outputs with mixed address and script fields', () => { const testAction = { outputs: [ { address: MOCK_DESTINATION_ADDRESS, sats: 1000n, }, { script: MOCK_DESTINATION_SCRIPT, sats: 2000n, }, ], }; const result = finalizeOutputs( testAction, // Sufficient sats for both outputs [{ ...DUMMY_UTXO, sats: 5000n }], () => DUMMY_CHANGE_SCRIPT, ); // The returned outputs should be processed correctly expect(result.txOutputs).to.have.length(2); // First output: address converted to script expect(result.txOutputs[0].sats).to.equal(1000n); expect(result.txOutputs[0].script).to.deep.equal( MOCK_DESTINATION_SCRIPT, ); // Second output: script unchanged expect(result.txOutputs[1].sats).to.equal(2000n); expect(result.txOutputs[1].script).to.deep.equal( MOCK_DESTINATION_SCRIPT, ); }); }); context('Validation rules common to all token types', () => { it('Throws if Action does not specify any outputs', () => { expect(() => finalizeOutputs( { outputs: [], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'No outputs specified. All actions must have outputs.', ); }); it('Throws if non-token Action specifies more than one OP_RETURN outputs', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n, script: new Script( new Uint8Array([OP_RETURN]), ), }, { sats: 0n, script: new Script( new Uint8Array([OP_RETURN]), ), }, ], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'ecash-wallet only supports 1 OP_RETURN per tx. 2 OP_RETURN outputs specified.', ); }); it('Throws if non-token Action specifies a blank OP_RETURN at index 0', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n, }, ], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'A blank OP_RETURN output (i.e. {sats: 0n}) is not allowed in a non-token tx.', ); }); it('Throws if non-token Action specifies a blank OP_RETURN at some other index', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 546n, script: DUMMY_SCRIPT }, { sats: 0n, }, ], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'A blank OP_RETURN output (i.e. {sats: 0n}) is not allowed in a non-token tx.', ); }); it('Throws if non-token Action burns XEC in the OP_RETURN', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 546n, script: DUMMY_SCRIPT }, { sats: 10n, script: new Script( new Uint8Array([OP_RETURN]), ), }, ], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Tx burns 10 satoshis in OP_RETURN output. ecash-wallet does not support burning XEC in the OP_RETURN.', ); }); it('Throws if a token Action DOES NOT specify a blank OP_RETURN at index 0', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 546n, tokenId: '11'.repeat(32), atoms: 100n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Token action requires a built OP_RETURN at index 0 of outputs, i.e. { sats: 0n }.', ); }); it('Throws if a token Action specifies a non-blank OP_RETURN at index 0', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n, script: new Script( new Uint8Array([OP_RETURN]), ), }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 100n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, ], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'A token tx cannot specify any manual OP_RETURN outputs. Token txs can only include a blank OP_RETURN output (i.e. { sats: 0n} at index 0.', ); }); it('Throws if Action includes a leftover output', () => { expect(() => finalizeOutputs( { outputs: [ DUMMY_SCRIPT as unknown as payment.PaymentOutput, ], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'ecash-wallet automatically includes a leftover output. Do not specify a leftover output in the outputs array.', ); }); it('Throws if Action includes genesis outputs but no genesisAction is specified', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Valid genesis mint qty outIdx { sats: 546n, script: DUMMY_SCRIPT, atoms: 100n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Valid genesis baton outIdx { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], // No genesisAction tokenActions: [ { type: 'MINT', tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Genesis outputs specified without GenesisAction. Must include GenesisAction or remove genesis outputs.', ); }); it('Throws if Action includes repeated tokenId in intentionalBurns', () => { const tokenIdThisAction = '11'.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: tokenIdThisAction, script: DUMMY_SCRIPT, atoms: 100n, isMintBaton: false, }, ], tokenActions: [ { type: 'BURN', tokenId: tokenIdThisAction, burnAtoms: 100n, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'BURN', tokenId: tokenIdThisAction, burnAtoms: 1n, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Duplicate BURN action for tokenId ${tokenIdThisAction}`, ); }); it('Throws if action token type is not supported', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: { ...SLP_TOKEN_TYPE_FUNGIBLE, type: 'SOME_UNSUPPORTED_TYPE', } as unknown as TokenType, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Unsupported tokenType SOME_UNSUPPORTED_TYPE.', ); }); it('Throws if user has specified any mint baton outputs with non-zero atoms', () => { const tokenIdThisAction = '11'.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Valid baton outIdx { tokenId: tokenIdThisAction, sats: 546n, script: DUMMY_SCRIPT, atoms: 1n, isMintBaton: true, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Mint baton outputs must have 0 atoms. Found 1 mint baton output with non-zero atoms.', ); }); }); context('DataAction validation', () => { it('Throws when user includes data action but no other token actions', () => { const dataAction: payment.DataAction = { type: 'DATA', data: new Uint8Array([1, 2, 3, 4]), }; expect(() => finalizeOutputs( { outputs: [{ sats: 0n }], tokenActions: [dataAction], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Data actions are only supported for ALP_TOKEN_TYPE_STANDARD token actions.', ); }); it('Throws when user includes data action but tokenActions is undefined', () => { // This test scenario is not possible since DataAction can only be in tokenActions // If tokenActions is undefined, there can't be a DataAction // This test would be redundant, so we skip it }); it('Throws when user includes data action with SLP token action', () => { const dataAction: payment.DataAction = { type: 'DATA', data: new Uint8Array([1, 2, 3, 4]), }; const slpSendAction: payment.SendAction = { type: 'SEND', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }; expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [slpSendAction, dataAction], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Data actions are only supported for ALP_TOKEN_TYPE_STANDARD token actions.', ); }); it('Does not throw when user includes p2shInputData with SLP SEND (chained tx)', () => { const slpSendAction: payment.SendAction = { type: 'SEND', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }; const slpUtxoWithEnoughAtoms = getDummySlpUtxo( 1_000_000n, '11'.repeat(32), ); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [slpSendAction], p2shInputData: { lokad: strToBytes('INP1'), data: strToBytes('test'), }, }, [slpUtxoWithEnoughAtoms], () => DUMMY_CHANGE_SCRIPT, ), ).to.not.throw(); }); it('Throws when user includes data action with SLP_TOKEN_TYPE_MINT_VAULT token action', () => { const dataAction: payment.DataAction = { type: 'DATA', data: new Uint8Array([1, 2, 3, 4]), }; const mintVaultGenesisAction: payment.GenesisAction = { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_MINT_VAULT, genesisInfo: nullGenesisInfo, }; expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, script: DUMMY_SCRIPT, atoms: 100n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, ], tokenActions: [mintVaultGenesisAction, dataAction], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Data actions are only supported for ALP_TOKEN_TYPE_STANDARD token actions.', ); }); it('Does not throw when user includes data action with ALP_TOKEN_TYPE_STANDARD token action', () => { const dataAction: payment.DataAction = { type: 'DATA', data: new Uint8Array([1, 2, 3, 4]), }; const alpSendAction: payment.SendAction = { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }; expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 100n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [alpSendAction, dataAction], }, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '11'.repeat(32), atoms: 1000n, // Ensure sufficient atoms tokenType: ALP_TOKEN_TYPE_STANDARD, isMintBaton: false, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).not.to.throw(); }); }); context('SLP_TOKEN_TYPE_FUNGIBLE', () => { it('Throws if action is associated with more than one tokenId', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, { sats: 546n, tokenId: '22'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: '22'.repeat(32), tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '22'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Action must include only one token type. Found (at least) two: ALP_TOKEN_TYPE_STANDARD and SLP_TOKEN_TYPE_FUNGIBLE.', ); }); it('Throws if we have a genesisAction and another token action', () => { expect(() => finalizeOutputs( { outputs: [ // Blank OP_RETURN { sats: 0n }, // Genesis mint qty at correct outIdx for SLP_TOKEN_TYPE_FUNGIBLE { sats: 546n, atoms: 1_000_000n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Token SEND output unrelated to genesis { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, genesisInfo: nullGenesisInfo, }, { type: 'SEND', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'SLP_TOKEN_TYPE_FUNGIBLE token txs may only have a single token action. 2 tokenActions specified.', ); }); it('Throws if we specify a token output in an SLP burn action', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // We specify a token receive output, invalid in SLP { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'BURN', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, burnAtoms: 1n, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `SLP burns may not specify SLP receive outputs. ecash-wallet will automatically calculate change from SLP burns.`, ); }); it('Throws if action combines MINT and SEND outputs', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Mint output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenIdThisAction}.`, ); }); it('Throws if action exceeds SLP_MAX_SEND_OUTPUTS max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); const atomsPerOutput = 1_000n; const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS + 1).fill( { sats: 546n, tokenId: tokenIdThisAction, atoms: atomsPerOutput, script: DUMMY_SCRIPT, isMintBaton: false, }, ); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: tokenIdThisAction, /** Exactly specify outputs so no change output is added */ atoms: BigInt(SLP_MAX_SEND_OUTPUTS + 1) * atomsPerOutput, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP SLP_TOKEN_TYPE_FUNGIBLE Action may not have more than ${SLP_MAX_SEND_OUTPUTS} token outputs, and no outputs may be at outIdx > ${SLP_MAX_SEND_OUTPUTS}. Found output at outIdx 20.`, ); }); it('Throws if generating a token change output will cause us to exceed SLP_TOKEN_TYPE_FUNGIBLE max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs and no change expected const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Tx needs a token change output to avoid burning atoms of ${tokenIdThisAction}, but the token change output would be at outIdx 20 which is greater than the maximum allowed outIdx of ${SLP_MAX_SEND_OUTPUTS} for SLP_TOKEN_TYPE_FUNGIBLE.`, ); }); it('DOES NOT throw if output atoms exactly match input atoms at max outputs, so no change output is generated', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs but requiredUtxos expect change const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: tokenIdThisAction, atoms: BigInt(SLP_MAX_SEND_OUTPUTS) * 1_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, returns processed outputs expect(result.txOutputs).to.have.length( SLP_MAX_SEND_OUTPUTS + 1, ); // Original action remains unchanged expect(testAction.outputs.length).to.equal( SLP_MAX_SEND_OUTPUTS + 1, ); }); it('DOES NOT throw and adds change if adding change does not push us over the output limit', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it with enough outputs so that 1 change output puts us at the max const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS - 1).fill( { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }; const outputsLengthBeforeChange = testAction.outputs.length; expect(outputsLengthBeforeChange).to.equal(19); const expectedChangeAtoms = 55n; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE.token, tokenId: tokenIdThisAction, atoms: BigInt(SLP_MAX_SEND_OUTPUTS - 1) * 1_000_000n + expectedChangeAtoms, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, check returned outputs expect(result.txOutputs).to.have.length( outputsLengthBeforeChange + 1, ); // The OP_RETURN has been written in the returned results const opReturn = result.txOutputs[0].script.toHex(); expect(opReturn).to.equal( `6a04534c500001010453454e442011111111111111111111111111111111111111111111111111111111111111110800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f4240080000000000000037`, ); // The last token output is the change output; it has expected atoms expect(BigInt(parseInt(opReturn.slice(-2), 16))).to.equal( expectedChangeAtoms, ); }); it('Throws if genesisAction does not include mint qty at outIdx 1', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Baton is at outIdx 1 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Genesis action for SLP_TOKEN_TYPE_FUNGIBLE token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_FUNGIBLE tokens.', ); }); it('Throws if MINT action does not include mint qty at outIdx 1', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Non-genesis mint baton output is at outIdx 1 { sats: 546n, script: DUMMY_SCRIPT, tokenId: '11'.repeat(32), atoms: 0n, isMintBaton: true, }, ], tokenActions: [ { type: 'MINT', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Mint action for SLP_TOKEN_TYPE_FUNGIBLE token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_FUNGIBLE tokens.`, ); }); it('Throws if genesisAction includes more than one mint baton', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, // Another mint baton output is at outIdx 3 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_FUNGIBLE GENESIS tx may only specify exactly 1 mint baton. Found second mint baton at outIdx 3.`, ); }); it('Throws if genesisAction includes more than one mint qty output', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, // Another mint qty output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_FUNGIBLE GENESIS tx may have only one mint qty output and it must be at outIdx 1. Found another mint qty output at outIdx 3.`, ); }); it('Throws if MINT action includes more than 1 mint baton', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 10n, isMintBaton: false, }, // Non-genesis mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 0n, isMintBaton: true, }, // Another non-genesis mint baton output is at outIdx 3 { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 0n, isMintBaton: true, }, ], tokenActions: [ { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_FUNGIBLE MINT tx may only specify exactly 1 mint baton. Found second mint baton at outIdx 3.`, ); }); it('Throws if MINT action includes more than 1 mint qty', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 10n, isMintBaton: false, }, // Non-genesis mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 0n, isMintBaton: true, }, // Another mint qty output { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 10n, isMintBaton: false, }, ], tokenActions: [ { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_FUNGIBLE MINT tx may have only one mint qty output and it must be at outIdx 1. Found another mint qty output at outIdx 3.`, ); }); it('Throws if genesisAction includes mint baton at first output index', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_FUNGIBLE, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_FUNGIBLE], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Genesis action for SLP_TOKEN_TYPE_FUNGIBLE token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_FUNGIBLE tokens.', ); }); }); context('SLP_TOKEN_TYPE_NFT1_GROUP', () => { it('Throws if action is associated with more than one tokenId', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, { sats: 546n, tokenId: '22'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: '22'.repeat(32), tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '22'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Action must include only one token type. Found (at least) two: ALP_TOKEN_TYPE_STANDARD and SLP_TOKEN_TYPE_NFT1_GROUP.', ); }); it('Throws if we have a genesisAction and another token action', () => { expect(() => finalizeOutputs( { outputs: [ // Blank OP_RETURN { sats: 0n }, // Genesis mint qty at correct outIdx for SLP_TOKEN_TYPE_FUNGIBLE { sats: 546n, atoms: 1_000_000n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Token SEND output unrelated to genesis { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: nullGenesisInfo, }, { type: 'SEND', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'SLP_TOKEN_TYPE_NFT1_GROUP token txs may only have a single token action. 2 tokenActions specified.', ); }); it('Throws if action combines MINT and SEND outputs', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Mint output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenIdThisAction}.`, ); }); it('Throws if action exceeds SLP_MAX_SEND_OUTPUTS max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); const atomsPerOutput = 1_000n; const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS + 1).fill( { sats: 546n, tokenId: tokenIdThisAction, atoms: atomsPerOutput, script: DUMMY_SCRIPT, isMintBaton: false, }, ); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: tokenIdThisAction, /** Exactly specify outputs so no change output is added */ atoms: BigInt(SLP_MAX_SEND_OUTPUTS + 1) * atomsPerOutput, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP SLP_TOKEN_TYPE_NFT1_GROUP Action may not have more than ${SLP_MAX_SEND_OUTPUTS} token outputs, and no outputs may be at outIdx > ${SLP_MAX_SEND_OUTPUTS}. Found output at outIdx 20.`, ); }); it('Throws if generating a token change output will cause us to exceed SLP max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs and no change expected const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Tx needs a token change output to avoid burning atoms of ${tokenIdThisAction}, but the token change output would be at outIdx 20 which is greater than the maximum allowed outIdx of ${SLP_MAX_SEND_OUTPUTS} for SLP_TOKEN_TYPE_NFT1_GROUP.`, ); }); it('DOES NOT throw if output atoms exactly match input atoms at max outputs, so no change output is generated', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs but requiredUtxos expect change const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: tokenIdThisAction, atoms: BigInt(SLP_MAX_SEND_OUTPUTS) * 1_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, returns processed outputs expect(result.txOutputs).to.have.length( SLP_MAX_SEND_OUTPUTS + 1, ); // Original action remains unchanged expect(testAction.outputs.length).to.equal( SLP_MAX_SEND_OUTPUTS + 1, ); }); it('DOES NOT throw and adds change if adding change does not push us over the output limit', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it with enough outputs so that 1 change output puts us at the max const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS - 1).fill( { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }; const outputsLengthBeforeChange = testAction.outputs.length; expect(outputsLengthBeforeChange).to.equal(19); const expectedChangeAtoms = 55n; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP.token, tokenId: tokenIdThisAction, atoms: BigInt(SLP_MAX_SEND_OUTPUTS - 1) * 1_000_000n + expectedChangeAtoms, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, check returned outputs expect(result.txOutputs).to.have.length( outputsLengthBeforeChange + 1, ); // The OP_RETURN has been written in the returned results const opReturn = result.txOutputs[0].script.toHex(); expect(opReturn).to.equal( `6a04534c500001${SLP_NFT1_GROUP.toString( 16, )}0453454e442011111111111111111111111111111111111111111111111111111111111111110800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f4240080000000000000037`, ); // The last token output is the change output; it has expected atoms expect(BigInt(parseInt(opReturn.slice(-2), 16))).to.equal( expectedChangeAtoms, ); }); it('Throws if genesisAction does not include mint qty at outIdx 1', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Baton is at outIdx 1 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Genesis action for SLP_TOKEN_TYPE_NFT1_GROUP token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_NFT1_GROUP tokens.', ); }); it('Throws if MINT action does not include mint qty at outIdx 1', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Non-genesis mint baton output is at outIdx 1 { sats: 546n, script: DUMMY_SCRIPT, tokenId: '11'.repeat(32), atoms: 0n, isMintBaton: true, }, ], tokenActions: [ { type: 'MINT', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Mint action for SLP_TOKEN_TYPE_NFT1_GROUP token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_NFT1_GROUP tokens.`, ); }); it('Throws if genesisAction includes more than one mint baton', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, // Another mint baton output is at outIdx 3 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [ DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_NFT1_GROUP GENESIS tx may only specify exactly 1 mint baton. Found second mint baton at outIdx 3.`, ); }); it('Throws if genesisAction includes more than one mint qty output', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, // Another mint qty output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [ DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_NFT1_GROUP GENESIS tx may have only one mint qty output and it must be at outIdx 1. Found another mint qty output at outIdx 3.`, ); }); it('Throws if MINT action includes more than 1 mint baton', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 10n, isMintBaton: false, }, // Non-genesis mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 0n, isMintBaton: true, }, // Another non-genesis mint baton output is at outIdx 3 { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 0n, isMintBaton: true, }, ], tokenActions: [ { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_NFT1_GROUP MINT tx may only specify exactly 1 mint baton. Found second mint baton at outIdx 3.`, ); }); it('Throws if MINT action includes more than 1 mint qty', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 10n, isMintBaton: false, }, // Non-genesis mint baton output is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 0n, isMintBaton: true, }, // Another mint qty output { sats: 546n, script: DUMMY_SCRIPT, tokenId: tokenIdThisAction, atoms: 10n, isMintBaton: false, }, ], tokenActions: [ { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, }, ] as payment.TokenAction[], }, [ DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP, DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_NFT1_GROUP_MINTBATON, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP_TOKEN_TYPE_NFT1_GROUP MINT tx may have only one mint qty output and it must be at outIdx 1. Found another mint qty output at outIdx 3.`, ); }); it('Throws if genesisAction includes mint baton at first output index', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_UTXO], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Genesis action for SLP_TOKEN_TYPE_NFT1_GROUP token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_NFT1_GROUP tokens.', ); }); }); context('ALP_TOKEN_TYPE_STANDARD', () => { it('Throws if we have SEND and MINT actions associated with the same tokenId', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Mint output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenIdThisAction}.`, ); }); it('Does not throw if we have SEND and MINT actions associated with different tokenIds', () => { const result = finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Mint output of a different token { sats: 546n, tokenId: '22'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: '22'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // Should return the processed outputs successfully (3 original + 1 change) expect(result.txOutputs).to.have.length(4); }); it('Throws if action exceeds ALP_POLICY_MAX_OUTPUTS max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); const tooManySendOutputs = Array( ALP_POLICY_MAX_OUTPUTS + 1, ).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '11'.repeat(32), atoms: BigInt(ALP_POLICY_MAX_OUTPUTS + 1) * 1_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An ALP ALP_TOKEN_TYPE_STANDARD Action may not have more than ${ALP_POLICY_MAX_OUTPUTS} token outputs, and no outputs may be at outIdx > ${ALP_POLICY_MAX_OUTPUTS}. Found output at outIdx 30.`, ); }); it('Throws if action has non-consecutive MINT mint batons for a tokenId', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint baton { sats: 546n, tokenId: tokenIdThisAction, atoms: 0n, script: DUMMY_SCRIPT, isMintBaton: true, }, // Normal XEC output { sats: 1_000n, script: DUMMY_SCRIPT }, // Non-consecutive mint baton { sats: 546n, tokenId: tokenIdThisAction, atoms: 0n, script: DUMMY_SCRIPT, isMintBaton: true, }, ], tokenActions: [ { type: 'MINT', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An ALP ALP_TOKEN_TYPE_STANDARD Action may only have consecutive mint baton outputs for the same tokenId. Found non-consecutive mint baton output at outIdx 3 for tokenId ${tokenIdThisAction}.`, ); }); it('Throws if action has non-consecutive GENESIS mint batons for a tokenId', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint baton { sats: 546n, atoms: 0n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, // Normal XEC output { sats: 1_000n, script: DUMMY_SCRIPT }, // Non-consecutive mint baton { sats: 546n, atoms: 0n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: ALP_TOKEN_TYPE_STANDARD, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An ALP ALP_TOKEN_TYPE_STANDARD Action may only have consecutive mint baton outputs for the same tokenId. Found non-consecutive mint baton output at outIdx 3 for GENESIS action.`, ); }); it('Throws if action has mint qty outputs at higher outIdx than mint batons for the same tokenId', () => { const tokenIdThisAction = `11`.repeat(32); expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint baton { sats: 546n, tokenId: tokenIdThisAction, atoms: 0n, script: DUMMY_SCRIPT, isMintBaton: true, }, // mint qty { sats: 546n, tokenId: tokenIdThisAction, atoms: 10n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'MINT', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `For a given tokenId, an ALP ALP_TOKEN_TYPE_STANDARD Action may not have mint qty outputs at a higher outIdx than mint baton outputs. Mint qty output for a tokenId with preceding mint batons found at outIdx 2.`, ); }); it('Throws if action has mint qty outputs at higher outIdx than mint batons for a GENESIS action', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint baton { sats: 546n, atoms: 0n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, // mint qty { sats: 546n, atoms: 10n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: ALP_TOKEN_TYPE_STANDARD, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `For a given tokenId, an ALP ALP_TOKEN_TYPE_STANDARD Action may not have mint qty outputs at a higher outIdx than mint baton outputs. Mint qty output for GENESIS action with preceding mint batons found at outIdx 2.`, ); }); it('Does not throw for a complex but on-spec ALP tx', () => { const result = finalizeOutputs( { outputs: [ // OP_RETURN placeholder { sats: 0n }, // Send output { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Mint output of a different token { sats: 546n, tokenId: '22'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Another send output of the first token { sats: 546n, tokenId: '11'.repeat(32), atoms: 2_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // A non-consecutive mint qty of the minting token { sats: 546n, tokenId: '22'.repeat(32), atoms: 2_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // A normal XEC send output { sats: 10_000_000n, script: DUMMY_SCRIPT }, // A mint baton of the minting token { sats: 546n, tokenId: '22'.repeat(32), atoms: 0n, script: DUMMY_SCRIPT, isMintBaton: true, }, // A consecutive mint baton of the minting token { sats: 546n, tokenId: '22'.repeat(32), atoms: 0n, script: DUMMY_SCRIPT, isMintBaton: true, }, // A normal XEC send output { sats: 10_000_000n, script: DUMMY_SCRIPT }, // A normal XEC send output { sats: 10_000_000n, script: DUMMY_SCRIPT }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'MINT', tokenId: '22'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [ DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '22'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // Should return the processed outputs successfully (10 original + 1 change) expect(result.txOutputs).to.have.length(11); }); it('Throws if generating a token change output will cause us to exceed ALP_TOKEN_TYPE_STANDARD max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); // Fill with max outputs, change will push it over the limit const tooManySendOutputs = Array(ALP_POLICY_MAX_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMint: false, isMintBaton: false, }); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Tx needs a token change output to avoid burning atoms of ${tokenIdThisAction}, but the token change output would be at outIdx 30 which is greater than the maximum allowed outIdx of ${ALP_POLICY_MAX_OUTPUTS} for ALP_TOKEN_TYPE_STANDARD.`, ); }); it('DOES NOT throw if output atoms exactly match input atoms at max outputs, so no change output is generated', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs but requiredUtxos expect change const tooManySendOutputs = Array(ALP_POLICY_MAX_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMint: false, isMintBaton: false, }); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdThisAction, // So no change expected atoms: BigInt(ALP_POLICY_MAX_OUTPUTS) * 1_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, returns processed outputs expect(result.txOutputs).to.have.length( ALP_POLICY_MAX_OUTPUTS + 1, ); // Original action remains unchanged expect(testAction.outputs.length).to.equal( ALP_POLICY_MAX_OUTPUTS + 1, ); }); it('DOES NOT throw and adds change for MULTIPLE TOKENS if adding change does not push us over the output limit', () => { const tokenIdFirstSend = `11`.repeat(32); const tokenIdSecondSend = `22`.repeat(32); // Fill it with enough outputs so that 1 change output puts us at the max // Note that, when we have two tokens, this is not 29 const firstSendOutputs = Array(4).fill({ sats: 546n, tokenId: tokenIdFirstSend, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); const secondSendOutputs = Array(5).fill({ sats: 546n, tokenId: tokenIdSecondSend, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); const testAction = { outputs: [ { sats: 0n }, ...firstSendOutputs, ...secondSendOutputs, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdFirstSend, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenIdSecondSend, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const outputsLengthBeforeChange = testAction.outputs.length; expect(outputsLengthBeforeChange).to.equal( 1 + firstSendOutputs.length + secondSendOutputs.length, ); const expectedChangeAtomsFirstToken = 55n; const expectedChangeAtomsSecondToken = 66n; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdFirstSend, atoms: BigInt(firstSendOutputs.length) * 1_000_000n + expectedChangeAtomsFirstToken, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdSecondSend, atoms: BigInt(secondSendOutputs.length) * 1_000_000n + expectedChangeAtomsSecondToken, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, returns processed outputs with change added expect(result.txOutputs).to.have.length( outputsLengthBeforeChange + 2, ); // We get the expected EMPP OP_RETURN in the results expect(result.txOutputs[0].script.toHex()).to.equal( `6a504c67534c5032000453454e4411111111111111111111111111111111111111111111111111111111111111110a40420f00000040420f00000040420f00000040420f0000000000000000000000000000000000000000000000000000000000000000003700000000004c6d534c5032000453454e4422222222222222222222222222222222222222222222222222222222222222220b00000000000000000000000000000000000000000000000040420f00000040420f00000040420f00000040420f00000040420f000000000000000000420000000000`, ); }); it('Throws if outputs are below ALP de facto max but OP_RETURN is above 223 bytes', () => { const burnAtomsFirstSend = 1n; const burnAtomsSecondSend = 2n; const tokenIdFirstSend = `11`.repeat(32); const tokenIdSecondSend = `22`.repeat(32); // Fill it with enough outputs so that 1 change output puts us at the max const firstSendOutputs = Array(5).fill({ sats: 546n, tokenId: tokenIdFirstSend, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMint: false, isMintBaton: false, }); const secondSendOutputs = Array(5).fill({ sats: 546n, tokenId: tokenIdSecondSend, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMint: false, isMintBaton: false, }); const testAction = { outputs: [ { sats: 0n }, ...firstSendOutputs, ...secondSendOutputs, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdFirstSend, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: tokenIdSecondSend, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'BURN', tokenId: tokenIdFirstSend, burnAtoms: burnAtomsFirstSend, tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'BURN', tokenId: tokenIdSecondSend, burnAtoms: burnAtomsSecondSend, tokenType: ALP_TOKEN_TYPE_STANDARD, }, ] as payment.TokenAction[], }; const outputsLengthBeforeChange = testAction.outputs.length; expect(outputsLengthBeforeChange).to.equal( 1 + firstSendOutputs.length + secondSendOutputs.length, ); const expectedChangeAtomsFirstToken = 55n; const expectedChangeAtomsSecondToken = 66n; const mockChangeScript = MOCK_DESTINATION_SCRIPT; expect(() => finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdFirstSend, atoms: BigInt(firstSendOutputs.length) * 1_000_000n + expectedChangeAtomsFirstToken, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenIdSecondSend, atoms: BigInt(secondSendOutputs.length) * 1_000_000n + expectedChangeAtomsSecondToken, } as Token, }, ], () => mockChangeScript, ), ).to.throw( Error, `Specified action results in OP_RETURN of 328 bytes, vs max allowed of 223.`, ); }); it('DOES NOT throw and adds adjusted change if BURN is specified and tx is otherwise valid, for 2 tokens', () => { const burnAtomsTokenOne = 1n; const burnAtomsTokenTwo = 2n; const tokenOne = `11`.repeat(32); const tokenTwo = `22`.repeat(32); // We will automatically generate an output as change for intentional burn const tokenOneOutputs: payment.PaymentOutput[] = []; const tokenTwoOutputs = [ { sats: 546n, tokenId: tokenTwo, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ]; const testAction = { outputs: [ { sats: 0n }, ...tokenOneOutputs, ...tokenTwoOutputs, ], tokenActions: [ { type: 'SEND', tokenId: tokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.SendAction, { type: 'SEND', tokenId: tokenTwo, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.SendAction, { type: 'BURN', tokenId: tokenOne, burnAtoms: burnAtomsTokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.BurnAction, { type: 'BURN', tokenId: tokenTwo, burnAtoms: burnAtomsTokenTwo, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.BurnAction, ], }; const outputsLengthBeforeChange = testAction.outputs.length; expect(outputsLengthBeforeChange).to.equal( 1 + tokenOneOutputs.length + tokenTwoOutputs.length, ); const expectedChangeAtomsFirstToken = 55n; const expectedChangeAtomsSecondToken = 66n; const mockChangeScript = MOCK_DESTINATION_SCRIPT; const testUtxos = [ { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenOne, atoms: BigInt(tokenOneOutputs.length) * 1_000_000n + expectedChangeAtomsFirstToken, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: tokenTwo, atoms: BigInt(tokenTwoOutputs.length) * 1_000_000n + expectedChangeAtomsSecondToken, } as Token, }, ]; const result = finalizeOutputs( testAction, testUtxos, () => mockChangeScript, ); // No error thrown, returns processed outputs expect(result.txOutputs).to.have.length( outputsLengthBeforeChange + 2, ); const ALP_SEND = '53454e44'; const ALP_BURN = '4255524e'; // NB we do not parse this EMPP string to find the appropriate change values // This behavior is confirmed in transactions.test.ts const opReturnHex = result.txOutputs[0].script?.toHex(); const sendOneEmpp = `37534c50320004${ALP_SEND}${tokenOne}02000000000000360000000000`; const sendTwoEmpp = `3d534c50320004${ALP_SEND}${tokenTwo}0340420f000000000000000000400000000000`; const burnOneEmpp = `30534c50320004${ALP_BURN}${tokenOne}010000000000`; const burnTwoEmpp = `30534c50320004${ALP_BURN}${tokenTwo}020000000000`; expect(opReturnHex).to.equal( `6a` + `50` + sendOneEmpp + sendTwoEmpp + burnOneEmpp + burnTwoEmpp, ); // We can change the order of EMPP pushes by changing the order of tokenActions const testActionsRearranged = { outputs: [ { sats: 0n }, ...tokenOneOutputs, ...tokenTwoOutputs, ], tokenActions: [ { type: 'BURN', tokenId: tokenTwo, burnAtoms: burnAtomsTokenTwo, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.BurnAction, { type: 'SEND', tokenId: tokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.SendAction, { type: 'BURN', tokenId: tokenOne, burnAtoms: burnAtomsTokenOne, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.BurnAction, { type: 'SEND', tokenId: tokenTwo, tokenType: ALP_TOKEN_TYPE_STANDARD, } as payment.SendAction, ], }; const resultRearranged = finalizeOutputs( testActionsRearranged, testUtxos, () => mockChangeScript, ); const opReturnHexRearranged = resultRearranged.txOutputs[0].script?.toHex(); // EMPP order matches user-specified ordering in tokenActions expect(opReturnHexRearranged).to.equal( `6a` + `50` + burnTwoEmpp + sendOneEmpp + burnOneEmpp + sendTwoEmpp, ); }); }); context('SLP_TOKEN_TYPE_MINT_VAULT', () => { it('Throws if action is associated with more than one tokenId', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, { sats: 546n, tokenId: '22'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: '11'.repeat(32), tokenType: ALP_TOKEN_TYPE_STANDARD, }, { type: 'SEND', tokenId: '22'.repeat(32), tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD, token: { ...DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD.token, tokenId: '22'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Action must include only one token type. Found (at least) two: ALP_TOKEN_TYPE_STANDARD and SLP_TOKEN_TYPE_MINT_VAULT.', ); }); it('Throws if we have a genesisAction and another token action', () => { expect(() => finalizeOutputs( { outputs: [ // Blank OP_RETURN { sats: 0n }, // Genesis mint qty at correct outIdx for SLP_TOKEN_TYPE_MINT_VAULT { sats: 546n, atoms: 1_000_000n, script: DUMMY_SCRIPT, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Token SEND output unrelated to genesis { sats: 546n, tokenId: '11'.repeat(32), atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_MINT_VAULT, genesisInfo: nullGenesisInfo, }, { type: 'SEND', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: '11'.repeat(32), atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'SLP_TOKEN_TYPE_MINT_VAULT token txs may only have a single token action. 2 tokenActions specified.', ); }); it('Throws if action combines MINT and SEND outputs', () => { const tokenIdThisAction = `11`.repeat(32); // Note we throw here because we don't support minting SLP_TOKEN_TYPE_MINT_VAULT tokens at all // But, when we do, we would still expect to throw here expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Send output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, // Mint output { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, { type: 'MINT', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `ecash-wallet does not currently support minting SLP_TOKEN_TYPE_MINT_VAULT tokens.`, ); }); it('Throws if action exceeds SLP_MAX_SEND_OUTPUTS max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); const atomsPerOutput = 1_000n; const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS + 1).fill( { sats: 546n, tokenId: tokenIdThisAction, atoms: atomsPerOutput, script: DUMMY_SCRIPT, isMintBaton: false, }, ); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: tokenIdThisAction, /** Exactly specify outputs so no change output is added */ atoms: BigInt(SLP_MAX_SEND_OUTPUTS + 1) * atomsPerOutput, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP SLP_TOKEN_TYPE_MINT_VAULT Action may not have more than ${SLP_MAX_SEND_OUTPUTS} token outputs, and no outputs may be at outIdx > ${SLP_MAX_SEND_OUTPUTS}. Found output at outIdx 20.`, ); }); it('Throws if generating a token change output will cause us to exceed SLP max outputs per tx', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs and no change expected const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); expect(() => finalizeOutputs( { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: tokenIdThisAction, atoms: 1_000_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `Tx needs a token change output to avoid burning atoms of ${tokenIdThisAction}, but the token change output would be at outIdx 20 which is greater than the maximum allowed outIdx of ${SLP_MAX_SEND_OUTPUTS} for SLP_TOKEN_TYPE_MINT_VAULT.`, ); }); it('DOES NOT throw if output atoms exactly match input atoms at max outputs, so no change output is generated', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it up with "just enough" outputs but requiredUtxos expect change const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS).fill({ sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: tokenIdThisAction, atoms: BigInt(SLP_MAX_SEND_OUTPUTS) * 1_000_000n, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, returns processed outputs expect(result.txOutputs).to.have.length( SLP_MAX_SEND_OUTPUTS + 1, ); // Original action remains unchanged expect(testAction.outputs.length).to.equal( SLP_MAX_SEND_OUTPUTS + 1, ); }); it('DOES NOT throw and adds change if adding change does not push us over the output limit', () => { const tokenIdThisAction = `11`.repeat(32); // Fill it with enough outputs so that 1 change output puts us at the max const tooManySendOutputs = Array(SLP_MAX_SEND_OUTPUTS - 1).fill( { sats: 546n, tokenId: tokenIdThisAction, atoms: 1_000_000n, script: DUMMY_SCRIPT, isMintBaton: false, }, ); const testAction = { outputs: [{ sats: 0n }, ...tooManySendOutputs], tokenActions: [ { type: 'SEND', tokenId: tokenIdThisAction, tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }; const outputsLengthBeforeChange = testAction.outputs.length; expect(outputsLengthBeforeChange).to.equal(19); const expectedChangeAtoms = 55n; const result = finalizeOutputs( testAction, [ { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT, token: { ...DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT.token, tokenId: tokenIdThisAction, atoms: BigInt(SLP_MAX_SEND_OUTPUTS - 1) * 1_000_000n + expectedChangeAtoms, } as Token, }, ], () => DUMMY_CHANGE_SCRIPT, ); // No error thrown, check returned outputs expect(result.txOutputs).to.have.length( outputsLengthBeforeChange + 1, ); // The OP_RETURN has been written in the returned results const opReturn = result.txOutputs[0].script.toHex(); expect(opReturn).to.equal( `6a04534c500001020453454e442011111111111111111111111111111111111111111111111111111111111111110800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f42400800000000000f4240080000000000000037`, ); // The last token output is the change output; it has expected atoms expect(BigInt(parseInt(opReturn.slice(-2), 16))).to.equal( expectedChangeAtoms, ); }); it('Throws if a genesisAction includes any mint batons', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Baton is at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, atoms: 0n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: true, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_MINT_VAULT, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'An SLP SLP_TOKEN_TYPE_MINT_VAULT Action may not have any mint batons.', ); }); it('Throws if genesisAction does not include mint qty at outIdx 1', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Sats at outIdx 1 { sats: 10000n, script: DUMMY_SCRIPT, }, // Mint qty at outIdx 2 { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_MINT_VAULT, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, 'Genesis action for SLP_TOKEN_TYPE_MINT_VAULT token specified, but no mint quantity output found at outIdx 1. This is a spec requirement for SLP SLP_TOKEN_TYPE_MINT_VAULT tokens.', ); }); it('Throws on MINT action (not currently supported)', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty { sats: 546n, script: DUMMY_SCRIPT, tokenId: '11'.repeat(32), atoms: 10n, isMintBaton: false, }, ], tokenActions: [ { type: 'MINT', tokenId: '11'.repeat(32), tokenType: SLP_TOKEN_TYPE_MINT_VAULT, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `ecash-wallet does not currently support minting SLP_TOKEN_TYPE_MINT_VAULT tokens.`, ); }); it('Throws if genesisAction includes more than one mint qty output', () => { expect(() => finalizeOutputs( { outputs: [ { sats: 0n }, // Mint qty output at outIdx 1, per spec { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, // Another mint qty output { sats: 546n, script: DUMMY_SCRIPT, atoms: 10n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, isMintBaton: false, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_MINT_VAULT, genesisInfo: nullGenesisInfo, }, ] as payment.TokenAction[], }, [DUMMY_TOKEN_UTXO_SLP_TOKEN_TYPE_MINT_VAULT], () => DUMMY_CHANGE_SCRIPT, ), ).to.throw( Error, `An SLP SLP_TOKEN_TYPE_MINT_VAULT GENESIS tx may have only one mint qty output and it must be at outIdx 1. Found another mint qty output at outIdx 2.`, ); }); }); }); context('getTokenUtxosWithExactAtoms', () => { it('Returns hasExact: false when DP limit is hit despite an exact match existing', () => { // Build a UTXO set where an exact match exists (100000 + 50000 = 150000) but the DP // hits the entry limit before finding it. First 17 UTXOs are powers of 2, which cause // the DP map to grow to 2^17 = 131072 entries (> 100k limit) before we process the // 18th UTXO. The exact match requires the 18th (100000) and 19th (50000) UTXOs. const tokenId = DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE; const atoms = [ 1n, 2n, 4n, 8n, 16n, 32n, 64n, 128n, 256n, 512n, 1024n, 2048n, 4096n, 8192n, 16384n, 32768n, 65536n, 100000n, 50000n, ]; const utxos = atoms.map((atom, i) => ({ ...getDummySlpUtxo(atom, tokenId), outpoint: { ...DUMMY_OUTPOINT, outIdx: 100 + i }, })); const burnAtoms = 150000n; // 100000 + 50000, the last two UTXOs const result = getTokenUtxosWithExactAtoms( utxos as unknown as WalletUtxo[], tokenId, burnAtoms, ); expect(result.hasExact).to.equal(false); // We hit the limit at 18 expect(result.burnUtxos.length).to.equal(18); const accumulatedAtoms = result.burnUtxos.reduce( (sum, u) => sum + (u.token?.atoms ?? 0n), 0n, ); expect(accumulatedAtoms).to.equal(231071n); // Greater than burnAtoms, but we will need a chained tx for an SLP burn }); }); }); describe('getWalletUtxoFromOutput', () => { const mockTxid = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; const mockOutIdx = 0; const mockOutputScript = MOCK_DESTINATION_SCRIPT.toHex(); const mockAddress = MOCK_DESTINATION_ADDRESS; context('Non-token UTXO', () => { it('Can get a non-token UTXO from a PaymentOutput', () => { const output: payment.PaymentOutput = { sats: 1000n, script: MOCK_DESTINATION_SCRIPT, }; const result = getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, ); expect(result).to.deep.equal({ outpoint: { txid: mockTxid, outIdx: mockOutIdx }, blockHeight: -1, sats: 1000n, isFinal: false, isCoinbase: false, address: mockAddress, }); }); }); context('Token UTXOs', () => { const tokenId = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; const tokenType = SLP_TOKEN_TYPE_FUNGIBLE; it('Can get a UTXO from a non-genesis mint baton output', () => { const output: payment.PaymentOutput = { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId, atoms: 0n, isMintBaton: true, }; const result = getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, tokenType, ); expect(result).to.deep.equal({ outpoint: { txid: mockTxid, outIdx: mockOutIdx }, blockHeight: -1, sats: 546n, isFinal: false, isCoinbase: false, token: { tokenId, tokenType, atoms: 0n, isMintBaton: true, }, address: mockAddress, }); }); it('Can get a UTXO from a genesis mint baton output', () => { const output: payment.PaymentOutput = { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 0n, isMintBaton: true, }; const result = getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, tokenType, ); expect(result).to.deep.equal({ outpoint: { txid: mockTxid, outIdx: mockOutIdx }, blockHeight: -1, sats: 546n, isFinal: false, isCoinbase: false, token: { tokenId: mockTxid, // Should be replaced with txid for genesis tokenType, atoms: 0n, isMintBaton: true, }, address: mockAddress, }); }); it('Can get a UTXO from a non-genesis non-mint-baton token output', () => { const output: payment.PaymentOutput = { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId, atoms: 1000n, isMintBaton: false, }; const result = getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, tokenType, ); expect(result).to.deep.equal({ outpoint: { txid: mockTxid, outIdx: mockOutIdx }, blockHeight: -1, sats: 546n, isFinal: false, isCoinbase: false, token: { tokenId, tokenType, atoms: 1000n, isMintBaton: false, }, address: mockAddress, }); }); it('Can get a UTXO from a genesis non-mint-baton output', () => { const output: payment.PaymentOutput = { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: GENESIS_TOKEN_ID_PLACEHOLDER, atoms: 1000n, isMintBaton: false, }; const result = getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, tokenType, ); expect(result).to.deep.equal({ outpoint: { txid: mockTxid, outIdx: mockOutIdx }, blockHeight: -1, sats: 546n, isFinal: false, isCoinbase: false, token: { tokenId: mockTxid, // Should be replaced with txid for genesis tokenType, atoms: 1000n, isMintBaton: false, }, address: mockAddress, }); }); }); context('Error cases', () => { it('Throws error when output has no sats', () => { const output: payment.PaymentOutput = { script: MOCK_DESTINATION_SCRIPT, }; expect(() => getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, ), ).to.throw('Output must have sats'); }); it('Throws error when token output has no tokenType', () => { const output: payment.PaymentOutput = { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', atoms: 1000n, isMintBaton: false, }; expect(() => getWalletUtxoFromOutput( output, mockTxid, mockOutIdx, mockOutputScript, ), ).to.throw('Token type is required for token utxos'); }); }); }); describe('batchTokenSendOutputs', () => { const createOutput = (index: number): payment.PaymentTokenOutput => ({ sats: 546n, script: DUMMY_SCRIPT, tokenId: '11'.repeat(32), atoms: BigInt(index) * 1000n, isMintBaton: false, }); context('Error cases', () => { it('Throws error when maxOutputsPerTx is 0', () => { expect(() => batchTokenSendOutputs([createOutput(0)], 0)).to.throw( 'batchTokenSendOutputs called with maxOutputsPerTx of 0; must be greater than 1', ); }); it('Throws error when maxOutputsPerTx is 1', () => { expect(() => batchTokenSendOutputs([createOutput(0)], 1)).to.throw( 'batchTokenSendOutputs called with maxOutputsPerTx of 1; must be greater than 1', ); }); it('Throws error when maxOutputsPerTx is negative', () => { expect(() => batchTokenSendOutputs([createOutput(0)], -1)).to.throw( 'batchTokenSendOutputs called with maxOutputsPerTx of -1; must be greater than 1', ); }); }); context('Empty array', () => { it('Returns empty array for empty input', () => { const result = batchTokenSendOutputs([], 19); expect(result).to.deep.equal([]); }); }); context('Single output', () => { it('Returns single batch with one output', () => { const outputs = [createOutput(0)]; const result = batchTokenSendOutputs(outputs, 19); expect(result).to.have.length(1); expect(result[0]).to.have.length(1); expect(result[0][0]).to.deep.equal(outputs[0]); }); }); context( 'Basic batching (maxOutputsPerTx = 19, maxEventOutputsPerTx = 18)', () => { const maxOutputsPerTx = 19; const maxEventOutputsPerTx = 18; it('Returns single batch when outputs = maxEventOutputsPerTx', () => { const outputs = Array.from( { length: maxEventOutputsPerTx }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(1); expect(result[0]).to.have.length(maxEventOutputsPerTx); }); it('Returns one batch when outputs = maxEventOutputsPerTx + 1 (optimization combines last batch, because chainedTxOmega does not need a change output)', () => { const outputs = Array.from( { length: maxEventOutputsPerTx + 1 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); // Optimization combines last batch of 1 element with previous batch // since maxOutputsPerTx = 19, the last tx can have 19 outputs expect(result).to.have.length(1); expect(result[0]).to.have.length(maxEventOutputsPerTx + 1); }); it('Returns two batches when outputs = maxEventOutputsPerTx * 2', () => { const outputs = Array.from( { length: maxEventOutputsPerTx * 2 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); expect(result[0]).to.have.length(maxEventOutputsPerTx); expect(result[1]).to.have.length(maxEventOutputsPerTx); }); it('Returns three batches when outputs = maxEventOutputsPerTx * 2 + 1', () => { const outputs = Array.from( { length: maxEventOutputsPerTx * 2 + 1 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); // Last batch with 1 element gets combined expect(result[0]).to.have.length(maxEventOutputsPerTx); expect(result[1]).to.have.length(maxEventOutputsPerTx + 1); }); it('Returns three batches when outputs = maxEventOutputsPerTx * 2 + 2', () => { const outputs = Array.from( { length: maxEventOutputsPerTx * 2 + 2 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(3); expect(result[0]).to.have.length(maxEventOutputsPerTx); expect(result[1]).to.have.length(maxEventOutputsPerTx); expect(result[2]).to.have.length(2); }); }, ); context('Last batch optimization (maxOutputsPerTx > 2)', () => { it('Combines last batch when it has 1 element (maxOutputsPerTx = 3)', () => { const maxOutputsPerTx = 3; const maxEventOutputsPerTx = 2; const outputs = Array.from( { length: maxEventOutputsPerTx * 2 + 1 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); // Combined from 3 to 2 expect(result[0]).to.have.length(maxEventOutputsPerTx); expect(result[1]).to.have.length(maxEventOutputsPerTx + 1); }); it('Does not combine last batch when it has 2 elements (maxOutputsPerTx = 3)', () => { const maxOutputsPerTx = 3; const maxEventOutputsPerTx = 2; const outputs = Array.from( { length: maxEventOutputsPerTx * 2 + 2 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(3); // Not combined expect(result[0]).to.have.length(maxEventOutputsPerTx); expect(result[1]).to.have.length(maxEventOutputsPerTx); expect(result[2]).to.have.length(2); }); it('Combines last batch with multiple preceding batches (maxOutputsPerTx = 5)', () => { const maxOutputsPerTx = 5; const maxEventOutputsPerTx = 4; const outputs = Array.from( { length: maxEventOutputsPerTx * 3 + 1 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(3); // Combined from 4 to 3 expect(result[0]).to.have.length(maxEventOutputsPerTx); expect(result[1]).to.have.length(maxEventOutputsPerTx); expect(result[2]).to.have.length(maxEventOutputsPerTx + 1); }); }); context('No optimization when maxOutputsPerTx = 2', () => { it('Does not combine last batch when maxOutputsPerTx = 2', () => { const maxOutputsPerTx = 2; const maxEventOutputsPerTx = 1; const outputs = Array.from( { length: maxEventOutputsPerTx * 2 + 1 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(3); // Not combined (maxOutputsPerTx = 2, so no optimization) expect(result[0]).to.have.length(1); expect(result[1]).to.have.length(1); expect(result[2]).to.have.length(1); }); }); context('Real-world values: SLP_MAX_SEND_OUTPUTS (19)', () => { const maxOutputsPerTx = SLP_MAX_SEND_OUTPUTS; // 19 it('Handles exactly 18 outputs (single batch)', () => { const outputs = Array.from({ length: 18 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(1); expect(result[0]).to.have.length(18); }); it('Handles 19 outputs (two batches, last combined; so one batch)', () => { const outputs = Array.from({ length: 19 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(1); // Combined: 18 + 1 = 19 (fits in one tx) expect(result[0]).to.have.length(19); }); it('Handles 36 outputs (two batches)', () => { const outputs = Array.from({ length: 36 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); expect(result[0]).to.have.length(18); expect(result[1]).to.have.length(18); }); it('Handles 37 outputs (two batches, last combined)', () => { const outputs = Array.from({ length: 37 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); // Combined: [18, 18+1=19] expect(result[0]).to.have.length(18); expect(result[1]).to.have.length(19); }); it('Handles 42 outputs (three batches, no combination)', () => { const outputs = Array.from({ length: 42 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); // 42 = 18 + 18 + 6, so 3 batches: [18, 18, 6] // Last batch has 6 elements (not 1), so no combination expect(result).to.have.length(3); expect(result[0]).to.have.length(18); expect(result[1]).to.have.length(18); expect(result[2]).to.have.length(6); }); }); context('Real-world values: ALP_POLICY_MAX_OUTPUTS (29)', () => { const maxOutputsPerTx = ALP_POLICY_MAX_OUTPUTS; // 29 it('Handles exactly 28 outputs (single batch)', () => { const outputs = Array.from({ length: 28 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(1); expect(result[0]).to.have.length(28); }); it('Handles 29 outputs (two batches, last combined)', () => { const outputs = Array.from({ length: 29 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(1); // Combined: 28 + 1 = 29 (fits in one tx) expect(result[0]).to.have.length(29); }); it('Handles 56 outputs (two batches)', () => { const outputs = Array.from({ length: 56 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); expect(result[0]).to.have.length(28); expect(result[1]).to.have.length(28); }); it('Handles 57 outputs (two batches, last combined)', () => { const outputs = Array.from({ length: 57 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, maxOutputsPerTx); expect(result).to.have.length(2); // Combined: [28, 28+1=29] expect(result[0]).to.have.length(28); expect(result[1]).to.have.length(29); }); }); context('Edge cases', () => { it('Preserves output order', () => { const outputs = Array.from({ length: 37 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, 19); // Flatten and check order const flattened = result.flat(); expect(flattened).to.have.length(37); for (let i = 0; i < 37; i++) { expect(flattened[i].atoms).to.equal(BigInt(i) * 1000n); } }); it('Handles very large number of outputs', () => { const outputs = Array.from({ length: 100 }, (_, i) => createOutput(i), ); const result = batchTokenSendOutputs(outputs, 19); // 100 outputs / 18 maxEventOutputsPerTx = 5.55... = 6 batches // Last batch: 100 % 18 = 10 outputs (not 1, so no combination) // Expected: 6 batches expect(result.length).to.equal(6); // Verify batch sizes expect(result[0]).to.have.length(18); expect(result[1]).to.have.length(18); expect(result[2]).to.have.length(18); expect(result[3]).to.have.length(18); expect(result[4]).to.have.length(18); expect(result[5]).to.have.length(10); }); }); }); describe('checkTokenSendExceedsMaxOutputs', () => { const tokenId1 = '11'.repeat(32); const tokenId2 = '22'.repeat(32); const DUMMY_SCRIPT = Script.p2pkh(fromHex('00'.repeat(20))); const createSendOutput = ( tokenId: string, atoms: bigint, ): payment.PaymentTokenOutput => ({ sats: 546n, script: DUMMY_SCRIPT, tokenId, atoms, isMintBaton: false, }); context('SLP tokens', () => { const tokenType = SLP_TOKEN_TYPE_FUNGIBLE; it('Returns false when no send actions', () => { const action: payment.Action = { outputs: [createSendOutput(tokenId1, 1000n)], tokenActions: [], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( false, ); }); it('Returns false when outputs are within limit (no change)', () => { const outputs = Array.from({ length: 18 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( false, ); }); it('Returns false when exactly at limit (18 outputs + OP_RETURN = 19)', () => { const outputs = Array.from({ length: 18 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( false, ); }); it('Returns true when outputs exceed limit (19 outputs + OP_RETURN = 20)', () => { const outputs = Array.from({ length: 19 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( true, ); }); it('Returns true when outputs exceed limit with change output', () => { const outputs = Array.from( { length: 18 }, (_, _i) => createSendOutput(tokenId1, 1000n), // All outputs have same atoms ); const totalOutputAtoms = 18000n; // 18 * 1000 const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; // Input atoms > output atoms, so change output will be added const tokens = new Map([ [ tokenId1, { atoms: totalOutputAtoms + 1n, // More than sum of outputs atomsMustBeExact: false, needsMintBaton: false, }, ], ]); // 18 outputs + 1 change + 1 OP_RETURN = 20 > 19 expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(true); }); it('Returns false when at limit with change output (18 outputs + 1 change + OP_RETURN = 19)', () => { const outputs = Array.from({ length: 17 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; // Input atoms > output atoms, so change output will be added const tokens = new Map([ [ tokenId1, { atoms: 20000n, // More than sum of outputs atomsMustBeExact: false, needsMintBaton: false, }, ], ]); // 17 outputs + 1 change + 1 OP_RETURN = 19 (at limit) expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(false); }); it('Returns false when no change needed (input atoms = output atoms)', () => { const outputs = Array.from({ length: 18 }, () => createSendOutput(tokenId1, 1000n), ); const totalOutputAtoms = 18000n; const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; const tokens = new Map([ [ tokenId1, { atoms: totalOutputAtoms, // Exactly matches output atoms atomsMustBeExact: false, needsMintBaton: false, }, ], ]); expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(false); }); it('Ignores genesis token outputs', () => { const outputs = [ createSendOutput(payment.GENESIS_TOKEN_ID_PLACEHOLDER, 1000n), ...Array.from({ length: 19 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ), ]; const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; // Genesis output should be ignored, so 19 outputs + OP_RETURN = 20 > 19 expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( true, ); }); it('Ignores outputs for different tokenIds', () => { const outputs = [ ...Array.from({ length: 18 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ), ...Array.from({ length: 5 }, (_, i) => createSendOutput(tokenId2, BigInt(i) * 1000n), ), ]; const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; // Only tokenId1 outputs count, so 18 outputs + OP_RETURN = 19 (at limit) expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( false, ); }); }); context('ALP tokens', () => { const tokenType = ALP_TOKEN_TYPE_STANDARD; it('Returns false when outputs are within limit (no change)', () => { const outputs = Array.from({ length: 28 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( false, ); }); it('Returns false when exactly at limit (28 outputs + OP_RETURN = 29)', () => { const outputs = Array.from({ length: 28 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( false, ); }); it('Returns true when outputs exceed limit (29 outputs + OP_RETURN = 30)', () => { const outputs = Array.from({ length: 29 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect(checkTokenSendExceedsMaxOutputs(action, tokenType)).to.equal( true, ); }); it('Returns true when outputs exceed limit with change output', () => { const outputs = Array.from( { length: 28 }, (_, _i) => createSendOutput(tokenId1, 1000n), // All outputs have same atoms ); const totalOutputAtoms = 28000n; // 28 * 1000 const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; // Input atoms > output atoms, so change output will be added const tokens = new Map([ [ tokenId1, { atoms: totalOutputAtoms + 1n, // More than sum of outputs atomsMustBeExact: false, needsMintBaton: false, }, ], ]); // 28 outputs + 1 change + 1 OP_RETURN = 30 > 29 expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(true); }); }); context('Edge cases', () => { const tokenType = SLP_TOKEN_TYPE_FUNGIBLE; it('Returns false when tokens map is undefined', () => { const outputs = Array.from({ length: 18 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; expect( checkTokenSendExceedsMaxOutputs(action, tokenType, undefined), ).to.equal(false); }); it('Returns false when tokenId not in tokens map', () => { const outputs = Array.from({ length: 18 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; const tokens = new Map([ [ tokenId2, // Different tokenId { atoms: 20000n, atomsMustBeExact: false, needsMintBaton: false, }, ], ]); expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(false); }); it('Returns false when input atoms <= output atoms (no change)', () => { const outputs = Array.from({ length: 18 }, () => createSendOutput(tokenId1, 1000n), ); const totalOutputAtoms = 18000n; const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; const tokens = new Map([ [ tokenId1, { atoms: totalOutputAtoms - 1n, // Less than output atoms atomsMustBeExact: false, needsMintBaton: false, }, ], ]); expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(false); }); it('Returns false when requiredTokenInputs.atoms is 0', () => { const outputs = Array.from({ length: 18 }, (_, i) => createSendOutput(tokenId1, BigInt(i) * 1000n), ); const action: payment.Action = { outputs: [{ sats: 0n }, ...outputs], tokenActions: [ { type: 'SEND', tokenId: tokenId1, tokenType, }, ], }; const tokens = new Map([ [ tokenId1, { atoms: 0n, // Zero atoms, no change atomsMustBeExact: false, needsMintBaton: false, }, ], ]); expect( checkTokenSendExceedsMaxOutputs(action, tokenType, tokens), ).to.equal(false); }); }); }); describe('static methods', () => { context('Can sum value of utxos', () => { it('Returns 0 if called with no utxos', () => { expect(WalletBase.sumUtxosSats([])).to.equal(0n); }); it('Can get total eCash satoshis held by all kinds of utxos', () => { expect(WalletBase.sumUtxosSats(ALL_SUPPORTED_UTXOS)).to.equal( 93754368n, ); }); }); }); describe('getMaxP2pkhOutputs', () => { context('More inputs and larger OP_RETURN allow fewer max outputs', () => { it('Returns correct max outputs for minimal inputs and no OP_RETURN', () => { expect(getMaxP2pkhOutputs(1, 0)).to.equal(2936); }); it('Returns correct max outputs with max OP_RETURN', () => { expect(getMaxP2pkhOutputs(1, OP_RETURN_MAX_BYTES)).to.equal(2929); }); it('Returns correct max outputs for multiple inputs and no OP_RETURN', () => { expect(getMaxP2pkhOutputs(10, 0)).to.equal(2899); }); it('Returns correct max outputs for multiple inputs and max OP_RETURN', () => { expect(getMaxP2pkhOutputs(10, OP_RETURN_MAX_BYTES)).to.equal(2892); }); }); context('Edge cases', () => { it('Handles zero inputs correctly, tho in practice this tx is not happening', () => { expect(getMaxP2pkhOutputs(0, 100)).to.equal(2937); }); }); context('Error cases', () => { it('Throws error when inputs (just) exceeds MAX_TX_SERSIZE', () => { let caught: unknown; try { getMaxP2pkhOutputs(709, 0); } catch (e) { caught = e; } expect(caught).to.be.instanceOf(MaxTxSersizeError); const mx = caught as MaxTxSersizeError; expect(mx.code).to.equal(MAX_TX_SERSIZE_ERROR_CODE); expect(mx.reason).to.equal('TOTAL_INPUTS_EXCEED'); expect(mx.maxTxSersize).to.equal(MAX_TX_SERSIZE); }); it('Does not throw when inputs are less than MAX_TX_SERSIZE', () => { // 709 p2pkh inputs is 99,969 bytes, leaving room for 31 p2pkh outputs // Enough to build a chained tx since you really only need 2 for chainedTxAlpha expect(() => getMaxP2pkhOutputs(708, 0)).to.not.throw(); }); }); context('Custom maxTxSersize parameter', () => { it('Works with a higher maxTxSersize value', () => { const higherMaxSize = MAX_TX_SERSIZE * 2; const resultWithDefault = getMaxP2pkhOutputs(1, 0); const resultWithHigher = getMaxP2pkhOutputs(1, 0, higherMaxSize); expect(resultWithDefault).to.equal(2936); // Higher size gives higher result expect(resultWithHigher).to.equal(5877); }); it('Works with a lower maxTxSersize value', () => { const lowerMaxSize = MAX_TX_SERSIZE / 2; const resultWithLower = getMaxP2pkhOutputs(1, 0, lowerMaxSize); const resultWithDefault = getMaxP2pkhOutputs(1, 0); expect(resultWithDefault).to.equal(2936); // Lower size gives lower result expect(resultWithLower).to.equal(1466); }); }); }); describe('WalletAction.build auto-consolidate for maxTxSersize', () => { it('prepends consolidation when a token SEND requires more than CONSOLIDATE_UTXOS_BATCHSIZE inputs', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); const inputCount = CONSOLIDATE_UTXOS_BATCHSIZE + 42; const tokenUtxos: WalletUtxo[] = Array.from( { length: inputCount }, (_, i) => ({ ...getDummySlpUtxo(1n), outpoint: { txid: i.toString(16).padStart(64, '0'), outIdx: 0, }, sats: 546n, }), ); const fuel: WalletUtxo = { ...DUMMY_UTXO, sats: 10_000_000n, outpoint: { txid: 'ee'.repeat(32), outIdx: 0 }, }; mockChronik.setUtxosByAddress(DUMMY_ADDRESS, [...tokenUtxos, fuel]); await testWallet.sync(); const built = testWallet .action({ outputs: [ { sats: 0n }, { sats: 546n, script: MOCK_DESTINATION_SCRIPT, tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, atoms: BigInt(inputCount), isMintBaton: false, }, ], tokenActions: [ { type: 'SEND', tokenId: DUMMY_TOKENID_SLP_TOKEN_TYPE_FUNGIBLE, tokenType: SLP_TOKEN_TYPE_FUNGIBLE, }, ], }) .build(); expect(built.txs).to.have.length(4); // The first consolidation tx maxes out p2pkh inputs expect(built.txs[0].inputs).to.have.length(CONSOLIDATE_UTXOS_BATCHSIZE); expect(built.txs[0].serSize()).to.equal(99972); expect(built.txs[1].inputs).to.have.length(43); expect(built.txs[1].serSize()).to.equal(6205); expect(built.txs[2].inputs).to.have.length(3); expect(built.txs[2].serSize()).to.equal(467); // And now we only need 1 token utxo for the actual action ... and also an XEC utxo expect(built.txs[3].inputs).to.have.length(2); expect(built.txs[3].serSize()).to.equal(424); }); it('prepends consolidation when an XEC send hits maxTxSersize from too many inputs', async () => { const mockChronik = new MockChronikClient(); const testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); const inputCount = CONSOLIDATE_UTXOS_BATCHSIZE + 50; const perUtxo = 10000n; const xecUtxos: WalletUtxo[] = Array.from( { length: inputCount }, (_, i) => ({ ...DUMMY_UTXO, sats: perUtxo, outpoint: { txid: i.toString(16).padStart(64, '0'), outIdx: 0, }, }), ); mockChronik.setUtxosByAddress(DUMMY_ADDRESS, xecUtxos); await testWallet.sync(); const sendSats = perUtxo * BigInt(inputCount) - 500000n; const built = testWallet .action({ outputs: [{ sats: sendSats, script: MOCK_DESTINATION_SCRIPT }], }) .build(); // We have 2 consolidation txs for this case expect(built.txs).to.have.length(3); // The first consolidation tx maxes out p2pkh inputs expect(built.txs[0].inputs).to.have.length(CONSOLIDATE_UTXOS_BATCHSIZE); expect(built.txs[0].serSize()).to.equal(99874); // Change from prev consolidation tx and the 50 utxos on top expect(built.txs[1].inputs).to.have.length(51); expect(built.txs[1].serSize()).to.equal(7235); // Now we only need 1 utxo for the actual action expect(built.txs[2].inputs).to.have.length(1); // Which is accordingly quite small indeed expect(built.txs[2].serSize()).to.equal(219); }); }); describe('removeSpentUtxos', () => { let mockChronik: MockChronikClient; let testWallet: Wallet; beforeEach(() => { mockChronik = new MockChronikClient(); testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); }); it('Removes a single UTXO when transaction has one matching input', () => { // Create a wallet with multiple UTXOs const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 0 }, sats: 546n, }; const utxo2: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '22'.repeat(32), outIdx: 0 }, sats: 1000n, }; const utxo3: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '33'.repeat(32), outIdx: 0 }, sats: 2000n, }; testWallet.utxos = [utxo1, utxo2, utxo3]; expect(testWallet.utxos.length).to.equal(3); // Create a transaction that spends utxo2 const tx = new Tx({ inputs: [ { prevOut: { txid: '22'.repeat(32), outIdx: 0, }, }, ], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // utxo2 should be removed, utxo1 and utxo3 should remain expect(testWallet.utxos.length).to.equal(2); expect(testWallet.utxos).to.deep.include(utxo1); expect(testWallet.utxos).to.deep.include(utxo3); expect(testWallet.utxos).to.not.deep.include(utxo2); }); it('Removes multiple UTXOs when transaction has multiple matching inputs', () => { // Create a wallet with multiple UTXOs const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 0 }, sats: 546n, }; const utxo2: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '22'.repeat(32), outIdx: 0 }, sats: 1000n, }; const utxo3: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '33'.repeat(32), outIdx: 0 }, sats: 2000n, }; const utxo4: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '44'.repeat(32), outIdx: 0 }, sats: 3000n, }; testWallet.utxos = [utxo1, utxo2, utxo3, utxo4]; expect(testWallet.utxos.length).to.equal(4); // Create a transaction that spends utxo1 and utxo3 const tx = new Tx({ inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, }, { prevOut: { txid: '33'.repeat(32), outIdx: 0, }, }, ], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // utxo1 and utxo3 should be removed, utxo2 and utxo4 should remain expect(testWallet.utxos.length).to.equal(2); expect(testWallet.utxos).to.deep.include(utxo2); expect(testWallet.utxos).to.deep.include(utxo4); expect(testWallet.utxos).to.not.deep.include(utxo1); expect(testWallet.utxos).to.not.deep.include(utxo3); }); it('Does not remove UTXOs when transaction has no matching inputs', () => { // Create a wallet with UTXOs const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 0 }, sats: 546n, }; const utxo2: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '22'.repeat(32), outIdx: 0 }, sats: 1000n, }; testWallet.utxos = [utxo1, utxo2]; expect(testWallet.utxos.length).to.equal(2); // Create a transaction that spends a UTXO not in the wallet const tx = new Tx({ inputs: [ { prevOut: { txid: '99'.repeat(32), outIdx: 0, }, }, ], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // All UTXOs should remain expect(testWallet.utxos.length).to.equal(2); expect(testWallet.utxos).to.deep.include(utxo1); expect(testWallet.utxos).to.deep.include(utxo2); }); it('Handles transaction with no inputs', () => { // Create a wallet with UTXOs const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 0 }, sats: 546n, }; testWallet.utxos = [utxo1]; expect(testWallet.utxos.length).to.equal(1); // Create a transaction with no inputs const tx = new Tx({ inputs: [], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // All UTXOs should remain expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos).to.deep.include(utxo1); }); it('Handles wallet with no UTXOs', () => { // Wallet starts with no UTXOs expect(testWallet.utxos.length).to.equal(0); // Create a transaction with inputs const tx = new Tx({ inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, }, ], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // Wallet should still have no UTXOs expect(testWallet.utxos.length).to.equal(0); }); it('Matches UTXOs by both txid and outIdx', () => { // Create UTXOs with same txid but different outIdx const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 0 }, sats: 546n, }; const utxo2: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 1 }, sats: 1000n, }; const utxo3: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 2 }, sats: 2000n, }; testWallet.utxos = [utxo1, utxo2, utxo3]; expect(testWallet.utxos.length).to.equal(3); // Create a transaction that spends only utxo2 (same txid, different outIdx) const tx = new Tx({ inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 1, }, }, ], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // Only utxo2 should be removed expect(testWallet.utxos.length).to.equal(2); expect(testWallet.utxos).to.deep.include(utxo1); expect(testWallet.utxos).to.deep.include(utxo3); expect(testWallet.utxos).to.not.deep.include(utxo2); }); it('Handles partial matches correctly - removes only matching UTXOs', () => { // Create a wallet with multiple UTXOs const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '11'.repeat(32), outIdx: 0 }, sats: 546n, }; const utxo2: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '22'.repeat(32), outIdx: 0 }, sats: 1000n, }; const utxo3: WalletUtxo = { ...DUMMY_UTXO, outpoint: { txid: '33'.repeat(32), outIdx: 0 }, sats: 2000n, }; testWallet.utxos = [utxo1, utxo2, utxo3]; expect(testWallet.utxos.length).to.equal(3); // Create a transaction with one matching input and one non-matching input const tx = new Tx({ inputs: [ { prevOut: { txid: '22'.repeat(32), outIdx: 0, }, }, { prevOut: { txid: '99'.repeat(32), outIdx: 0, }, }, ], }); // Remove spent UTXOs removeSpentUtxos(testWallet, tx); // Only utxo2 should be removed expect(testWallet.utxos.length).to.equal(2); expect(testWallet.utxos).to.deep.include(utxo1); expect(testWallet.utxos).to.deep.include(utxo3); expect(testWallet.utxos).to.not.deep.include(utxo2); }); }); describe('HD Wallet', () => { const testMnemonic = 'shift satisfy hammer fit plunge swear athlete gentle tragic sorry blush cheap'; it('The fromMnemonic static constructor creates a non-HD wallet by default', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, ); expect(wallet.isHD).to.equal(false); expect(wallet.baseHdNode).to.equal(undefined); expect(wallet.accountNumber).to.equal(0); expect(wallet.receiveIndex).to.equal(0); expect(wallet.changeIndex).to.equal(0); expect(wallet.keypairs.size).to.equal(1); }); it('Creates HD wallet when hd option is true', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); expect(wallet.isHD).to.equal(true); expect(wallet.baseHdNode).to.not.equal(undefined); expect(wallet.accountNumber).to.equal(0); // Default account number expect(wallet.receiveIndex).to.equal(0); expect(wallet.changeIndex).to.equal(0); expect(wallet.keypairs.size).to.equal(1); // First receive address is cached }); it('Creates HD wallet with custom account number', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 5 }, ); expect(wallet.isHD).to.equal(true); expect(wallet.baseHdNode).to.not.equal(undefined); expect(wallet.accountNumber).to.equal(5); }); it('Creates HD wallet with custom receive and change indices', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, receiveIndex: 10, changeIndex: 3 }, ); expect(wallet.isHD).to.equal(true); expect(wallet.receiveIndex).to.equal(10); expect(wallet.changeIndex).to.equal(3); }); it('HD wallet caches first receive address on creation', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); expect(wallet.keypairs.size).to.equal(1); expect(wallet.address).to.not.equal(undefined); expect(wallet.keypairs.has(wallet.address)).to.equal(true); const keypair = wallet.keypairs.get(wallet.address); expect(keypair).to.not.equal(undefined); expect(keypair!.address).to.equal(wallet.address); expect(keypair!.sk).to.deep.equal(wallet.sk); expect(keypair!.pk).to.deep.equal(wallet.pk); expect(keypair!.pkh).to.deep.equal(wallet.pkh); expect(keypair!.script).to.deep.equal(wallet.script); }); it('HD wallets with same mnemonic and account number generate same base address', () => { const mockChronik = new MockChronikClient(); const wallet1 = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 0 }, ); const wallet2 = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 0 }, ); expect(wallet1.address).to.equal(wallet2.address); expect(wallet1.sk).to.deep.equal(wallet2.sk); }); it('HD wallets with different account numbers generate different addresses', () => { const mockChronik = new MockChronikClient(); const wallet1 = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 0 }, ); const wallet2 = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 1 }, ); expect(wallet1.address).to.not.equal(wallet2.address); expect(wallet1.sk).to.not.deep.equal(wallet2.sk); }); it('HD wallet clone preserves HD wallet state', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 2, receiveIndex: 5, changeIndex: 3 }, ); const cloned = wallet.clone(); expect(cloned.isHD).to.equal(true); expect(cloned.accountNumber).to.equal(2); expect(cloned.receiveIndex).to.equal(5); expect(cloned.changeIndex).to.equal(3); expect(cloned.keypairs.size).to.equal(wallet.keypairs.size); expect(cloned.baseHdNode).to.not.equal(undefined); }); it('Non-HD wallet clone preserves non-HD state', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, ); const cloned = wallet.clone(); expect(cloned.isHD).to.equal(false); expect(cloned.baseHdNode).to.equal(undefined); expect(cloned.accountNumber).to.equal(0); }); it('HD wallet derives correct base path for account 0', () => { const mockChronik = new MockChronikClient(); const seed = mnemonicToSeed(testMnemonic); const master = HdNode.fromSeed(seed); const expectedBaseNode = master.derivePath(`m/44'/1899'/0'`); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 0 }, ); expect(wallet.baseHdNode).to.not.equal(undefined); expect(toHex(wallet.baseHdNode!.seckey()!)).to.equal( toHex(expectedBaseNode.seckey()!), ); }); it('HD wallet derives correct base path for account 3', () => { const mockChronik = new MockChronikClient(); const seed = mnemonicToSeed(testMnemonic); const master = HdNode.fromSeed(seed); const expectedBaseNode = master.derivePath(`m/44'/1899'/3'`); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 3 }, ); expect(wallet.baseHdNode).to.not.equal(undefined); expect(toHex(wallet.baseHdNode!.seckey()!)).to.equal( toHex(expectedBaseNode.seckey()!), ); }); it('getKeypairForAddress returns keypair for cached address', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const keypair = wallet.getKeypairForAddress(wallet.address); expect(keypair).to.not.equal(undefined); expect(keypair!.address).to.equal(wallet.address); expect(keypair!.sk).to.deep.equal(wallet.sk); }); it('getKeypairForAddress returns undefined for unknown address', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const keypair = wallet.getKeypairForAddress('ecash:unknown'); expect(keypair).to.equal(undefined); }); it('getReceiveAddress returns address at specific index', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const address0 = wallet.getReceiveAddress(0); const address1 = wallet.getReceiveAddress(1); const address2 = wallet.getReceiveAddress(2); console.log('address0', address0); console.log('address1', address1); console.log('address2', address2); expect(address0).to.equal(wallet.address); // First address is already cached // The addresses are distinct expect(address0).to.equal( 'ecash:qq86jv6h0y97q8l63ndynvk3fn9aq8fqru3exew8gl', ); expect(address1).to.equal( 'ecash:qpvd5zt8727jjgx97t7hhlhqj9pdcgfrsvrf880jcw', ); expect(address2).to.equal( 'ecash:qp8knv98nlrafajqgc53r4h9cw3yzazx7g30k779v6', ); }); it('getReceiveAddress does not increment receiveIndex', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialIndex = wallet.receiveIndex; wallet.getReceiveAddress(5); expect(wallet.receiveIndex).to.equal(initialIndex); }); it('getReceiveAddress caches keypairs', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialSize = wallet.keypairs.size; const address = wallet.getReceiveAddress(10); expect(wallet.keypairs.size).to.equal(initialSize + 1); expect(wallet.getKeypairForAddress(address)).to.not.equal(undefined); }); it('getChangeAddress returns address at specific index', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const change0 = wallet.getChangeAddress(0); const change1 = wallet.getChangeAddress(1); const receive0 = wallet.getReceiveAddress(0); expect(change0).to.not.equal(receive0); expect(change1).to.not.equal(change0); }); it('getChangeAddress does not increment changeIndex', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialIndex = wallet.changeIndex; wallet.getChangeAddress(7); expect(wallet.changeIndex).to.equal(initialIndex); }); it('getNextReceiveAddress increments receiveIndex', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialIndex = wallet.receiveIndex; const address0 = wallet.getNextReceiveAddress(); expect(wallet.receiveIndex).to.equal(initialIndex + 1); const address1 = wallet.getNextReceiveAddress(); expect(wallet.receiveIndex).to.equal(initialIndex + 2); expect(address1).to.not.equal(address0); }); it('getNextReceiveAddress caches keypairs', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialSize = wallet.keypairs.size; // 1 (first receive address at index 0) const address0 = wallet.getNextReceiveAddress(); // Gets index 0 (already cached), increments to 1 expect(address0).to.equal(wallet.address); expect(wallet.keypairs.size).to.equal(initialSize); // Still 1, no new cache const address1 = wallet.getNextReceiveAddress(); // Gets index 1 (new), increments to 2 expect(wallet.keypairs.size).to.equal(initialSize + 1); // Now 2, new address cached expect(wallet.getKeypairForAddress(address1)).to.not.equal(undefined); }); it('getNextChangeAddress increments changeIndex', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialIndex = wallet.changeIndex; const address0 = wallet.getNextChangeAddress(); expect(wallet.changeIndex).to.equal(initialIndex + 1); const address1 = wallet.getNextChangeAddress(); expect(wallet.changeIndex).to.equal(initialIndex + 2); expect(address1).to.not.equal(address0); }); it('getAllAddresses returns all cached addresses', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); const initialAddresses = wallet.getAllAddresses(); expect(initialAddresses.length).to.equal(1); // First receive address wallet.getReceiveAddress(1); wallet.getReceiveAddress(2); wallet.getChangeAddress(0); wallet.getChangeAddress(1); const allAddresses = wallet.getAllAddresses(); expect(allAddresses.length).to.equal(5); expect(allAddresses).to.include(wallet.address); }); it('HD wallet methods throw error on non-HD wallet', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, ); // Non-HD wallet expect(() => wallet.getReceiveAddress(0)).to.throw( 'getReceiveAddress can only be called on HD wallets', ); expect(() => wallet.getChangeAddress(0)).to.throw( 'getChangeAddress can only be called on HD wallets', ); expect(() => wallet.getNextReceiveAddress()).to.throw( 'getNextReceiveAddress can only be called on HD wallets', ); expect(() => wallet.getNextChangeAddress()).to.throw( 'getNextChangeAddress can only be called on HD wallets', ); }); it('getAllAddresses returns single address in array for non-HD wallet', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, ); // Non-HD wallet expect(wallet.getAllAddresses()).to.deep.equal([wallet.address]); }); it('HD wallet sync queries UTXOs for all addresses at or below current indices', async () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, receiveIndex: 2, changeIndex: 1 }, ); // Get addresses for indices 0, 1, 2 (receive) and 0, 1 (change) const receive0 = wallet.getReceiveAddress(0); const receive1 = wallet.getReceiveAddress(1); const receive2 = wallet.getReceiveAddress(2); const change0 = wallet.getChangeAddress(0); const change1 = wallet.getChangeAddress(1); // Set up mock UTXOs for each address const utxo0: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 0 }, sats: 1000n, }; const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 1 }, sats: 2000n, }; const utxo2: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 2 }, sats: 3000n, }; const utxo3: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 3 }, sats: 4000n, }; const utxo4: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 4 }, sats: 5000n, }; mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); mockChronik.setUtxosByAddress(receive0, [utxo0]); mockChronik.setUtxosByAddress(receive1, [utxo1]); mockChronik.setUtxosByAddress(receive2, [utxo2]); mockChronik.setUtxosByAddress(change0, [utxo3]); mockChronik.setUtxosByAddress(change1, [utxo4]); await wallet.sync(); // All UTXOs should be merged expect(wallet.utxos.length).to.equal(5); expect(wallet.tipHeight).to.equal(DUMMY_TIPHEIGHT); }); it('HD wallet sync derives missing addresses when keypairs count is less than expected', async () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, receiveIndex: 3, changeIndex: 2 }, ); // Initially only first receive address is cached expect(wallet.keypairs.size).to.equal(1); expect(wallet.getAllAddresses().length).to.equal(1); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Set empty UTXOs for all addresses (they still need to be checked) const receive0 = wallet.getReceiveAddress(0); const receive1 = wallet.getReceiveAddress(1); const receive2 = wallet.getReceiveAddress(2); const receive3 = wallet.getReceiveAddress(3); const change0 = wallet.getChangeAddress(0); const change1 = wallet.getChangeAddress(1); const change2 = wallet.getChangeAddress(2); mockChronik.setUtxosByAddress(receive0, []); mockChronik.setUtxosByAddress(receive1, []); mockChronik.setUtxosByAddress(receive2, []); mockChronik.setUtxosByAddress(receive3, []); mockChronik.setUtxosByAddress(change0, []); mockChronik.setUtxosByAddress(change1, []); mockChronik.setUtxosByAddress(change2, []); await wallet.sync(); // All addresses should now be cached (4 receive + 3 change = 7 total) expect(wallet.keypairs.size).to.equal(7); expect(wallet.getAllAddresses().length).to.equal(7); expect(wallet.keypairs.has(receive0)).to.equal(true); expect(wallet.keypairs.has(receive1)).to.equal(true); expect(wallet.keypairs.has(receive2)).to.equal(true); expect(wallet.keypairs.has(receive3)).to.equal(true); expect(wallet.keypairs.has(change0)).to.equal(true); expect(wallet.keypairs.has(change1)).to.equal(true); expect(wallet.keypairs.has(change2)).to.equal(true); }); it('HD wallet sync uses cached addresses when keypairs count matches expected', async () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, receiveIndex: 1, changeIndex: 1 }, ); // Pre-cache all addresses const receive0 = wallet.getReceiveAddress(0); const receive1 = wallet.getReceiveAddress(1); const change0 = wallet.getChangeAddress(0); const change1 = wallet.getChangeAddress(1); // Verify all addresses are cached expect(wallet.keypairs.size).to.equal(4); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Set UTXOs const utxo0: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 0 }, sats: 1000n, }; const utxo1: WalletUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 1 }, sats: 2000n, }; mockChronik.setUtxosByAddress(receive0, [utxo0]); mockChronik.setUtxosByAddress(receive1, [utxo1]); mockChronik.setUtxosByAddress(change0, []); mockChronik.setUtxosByAddress(change1, []); await wallet.sync(); // Should have synced all addresses expect(wallet.utxos.length).to.equal(2); expect(wallet.tipHeight).to.equal(DUMMY_TIPHEIGHT); }); it('HD wallet sync merges UTXOs from multiple addresses', async () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, receiveIndex: 1, changeIndex: 1 }, ); const receive0 = wallet.getReceiveAddress(0); const receive1 = wallet.getReceiveAddress(1); const change0 = wallet.getChangeAddress(0); const change1 = wallet.getChangeAddress(1); mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); // Set multiple UTXOs per address const utxos0: WalletUtxo[] = [ { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 0 }, sats: 1000n, }, { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 1 }, sats: 2000n, }, ]; const utxos1: WalletUtxo[] = [ { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 2 }, sats: 3000n, }, ]; const utxos2: WalletUtxo[] = [ { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 3 }, sats: 4000n, }, { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 4 }, sats: 5000n, }, ]; const utxos3: WalletUtxo[] = [ { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 5 }, sats: 6000n, }, ]; mockChronik.setUtxosByAddress(receive0, utxos0); mockChronik.setUtxosByAddress(receive1, utxos1); mockChronik.setUtxosByAddress(change0, utxos2); mockChronik.setUtxosByAddress(change1, utxos3); await wallet.sync(); // All UTXOs should be merged (2 + 1 + 2 + 1 = 6 total) expect(wallet.utxos.length).to.equal(6); // Verify total sats const totalSats = wallet.utxos.reduce( (sum, utxo) => sum + utxo.sats, 0n, ); expect(totalSats).to.equal(21000n); // 1000 + 2000 + 3000 + 4000 + 5000 + 6000 }); it('Non-HD wallet sync still works as before', async () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, ); // Non-HD wallet mockChronik.setBlockchainInfo({ tipHash: DUMMY_TIPHASH, tipHeight: DUMMY_TIPHEIGHT, }); mockChronik.setUtxosByAddress(wallet.address, [DUMMY_UTXO]); await wallet.sync(); expect(wallet.utxos).to.deep.equal([ { ...DUMMY_UTXO, address: wallet.address }, ]); expect(wallet.tipHeight).to.equal(DUMMY_TIPHEIGHT); }); describe('addReceivedTx', () => { let mockChronik: MockChronikClient; let testWallet: Wallet; const OTHER_SK = fromHex('33'.repeat(32)); const OTHER_PK = testEcc.derivePubkey(OTHER_SK); const OTHER_HASH = shaRmd160(OTHER_PK); const OTHER_SCRIPT = Script.p2pkh(OTHER_HASH); beforeEach(() => { mockChronik = new MockChronikClient(); testWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); testWallet.balanceSats = 0n; testWallet.utxos = []; }); it('Adds a received non-token UTXO and updates balance', () => { const txid = 'aa'.repeat(32); const sats = 100_000n; const blockHeight = 800001; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, block: { hash: DUMMY_TIPHASH, height: blockHeight, timestamp: 1234567890, }, }; expect(testWallet.utxos.length).to.equal(0); const initialBalance = testWallet.balanceSats; expect(initialBalance).to.equal(0n); const result = testWallet.addReceivedTx(tx); expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0]).to.deep.equal({ outpoint: { txid, outIdx: 0 }, blockHeight, isCoinbase: false, sats, isFinal: true, address: DUMMY_ADDRESS, }); expect(testWallet.balanceSats).to.equal(sats); expect(result.balanceSatsDelta).to.equal(sats); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Adds a received token UTXO but does not update balance', () => { const txid = 'bb'.repeat(32); const sats = 546n; const tokenId = 'cc'.repeat(32); const atoms = 1000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms, isMintBaton: false, }, }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; expect(testWallet.utxos.length).to.equal(0); const initialBalance = testWallet.balanceSats; expect(initialBalance).to.equal(0n); const result = testWallet.addReceivedTx(tx); expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0].token).to.deep.equal({ tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms, isMintBaton: false, }); // Balance should not be updated for token UTXOs expect(testWallet.balanceSats).to.equal(0n); expect(result.balanceSatsDelta).to.equal(0n); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Ignores outputs with spentBy key', () => { const txid = 'dd'.repeat(32); const sats = 50_000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), spentBy: { txid: 'ee'.repeat(32), outIdx: 0, }, }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); // UTXO should not be added because it has spentBy expect(testWallet.utxos.length).to.equal(0); expect(testWallet.balanceSats).to.equal(0n); expect(result.balanceSatsDelta).to.equal(0n); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Ignores outputs that do not belong to the wallet', () => { const txid = 'ff'.repeat(32); const sats = 75_000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: OTHER_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); // UTXO should not be added because it doesn't belong to this wallet expect(testWallet.utxos.length).to.equal(0); expect(testWallet.balanceSats).to.equal(0n); expect(result.balanceSatsDelta).to.equal(0n); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Ignores outputs that already exist in the UTXO set', () => { const txid = '11'.repeat(32); const sats = 25_000n; // Add UTXO manually first testWallet.utxos.push({ outpoint: { txid, outIdx: 0 }, blockHeight: DUMMY_TIPHEIGHT, isCoinbase: false, sats, isFinal: true, address: DUMMY_ADDRESS, }); testWallet.balanceSats = sats; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); // UTXO should not be added again (no duplicates) expect(testWallet.utxos.length).to.equal(1); expect(testWallet.balanceSats).to.equal(initialBalance); expect(result.balanceSatsDelta).to.equal(0n); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Handles multiple outputs in a transaction', () => { const txid = '22'.repeat(32); const sats1 = 30_000n; const sats2 = 40_000n; const sats3 = 50_000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats: sats1, outputScript: OTHER_SCRIPT.toHex(), // Not wallet's address }, { sats: sats2, outputScript: DUMMY_SCRIPT.toHex(), // Wallet's address }, { sats: sats3, outputScript: DUMMY_SCRIPT.toHex(), // Wallet's address }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); // Should add 2 UTXOs (outputs 1 and 2 belong to wallet) expect(testWallet.utxos.length).to.equal(2); expect(testWallet.utxos[0].outpoint.outIdx).to.equal(1); expect(testWallet.utxos[0].sats).to.equal(sats2); expect(testWallet.utxos[1].outpoint.outIdx).to.equal(2); expect(testWallet.utxos[1].sats).to.equal(sats3); expect(testWallet.balanceSats).to.equal(sats2 + sats3); expect(result.balanceSatsDelta).to.equal(sats2 + sats3); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Skips OP_RETURN outputs and processes valid outputs', () => { const txid = '55'.repeat(32); const opReturnScript = '6a5037534c5032000453454e44f0cb08302c4bbc665b6241592b19fd37ec5d632f323e9ab14fdb75d57f94870302f40100000000c20300000000'; const validSats = 100_000n; const tx: ChronikTx = { txid, version: 2, inputs: [], outputs: [ { sats: 0n, outputScript: opReturnScript, // OP_RETURN output }, { sats: validSats, outputScript: DUMMY_SCRIPT.toHex(), // Valid wallet output }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; expect(initialBalance).to.equal(0n); // Should not throw an error const result = testWallet.addReceivedTx(tx); // OP_RETURN output should be skipped, valid output should be added expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0].outpoint.outIdx).to.equal(1); // Second output expect(testWallet.utxos[0].sats).to.equal(validSats); expect(testWallet.balanceSats).to.equal(validSats); expect(result.balanceSatsDelta).to.equal(validSats); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Skips unsupported output script types gracefully', () => { const txid = '66'.repeat(32); const unsupportedScript = 'ff00'; // Invalid/unsupported script const validSats = 75_000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats: 0n, outputScript: unsupportedScript, // Unsupported script type }, { sats: validSats, outputScript: DUMMY_SCRIPT.toHex(), // Valid wallet output }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; expect(initialBalance).to.equal(0n); // Should not throw an error, should catch and skip unsupported script const result = testWallet.addReceivedTx(tx); // Unsupported output should be skipped, valid output should be added expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0].outpoint.outIdx).to.equal(1); // Second output expect(testWallet.utxos[0].sats).to.equal(validSats); expect(testWallet.balanceSats).to.equal(validSats); expect(result.balanceSatsDelta).to.equal(validSats); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Handles mempool transaction (no block)', () => { const txid = '33'.repeat(32); const sats = 60_000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: false, // No block field - mempool transaction }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0].blockHeight).to.equal(-1); // Mempool expect(testWallet.utxos[0].isFinal).to.equal(false); expect(result.balanceSatsDelta).to.equal(sats); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Handles coinbase transaction', () => { const txid = '44'.repeat(32); const sats = 31_250_000n; const blockHeight = 800002; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: true, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, block: { hash: DUMMY_TIPHASH, height: blockHeight, timestamp: 1234567890, }, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0].isCoinbase).to.equal(true); expect(testWallet.utxos[0].blockHeight).to.equal(blockHeight); expect(result.balanceSatsDelta).to.equal(sats); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Works with HD wallets', async () => { const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; const hdWallet = Wallet.fromMnemonic( mnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); // Get the first receive address const receiveAddress = hdWallet.getReceiveAddress(0); const receiveScript = Script.fromAddress(receiveAddress); const txid = '55'.repeat(32); const sats = 80_000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: receiveScript.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; expect(hdWallet.utxos.length).to.equal(0); const initialBalance = hdWallet.balanceSats; expect(initialBalance).to.equal(0n); const result = hdWallet.addReceivedTx(tx); expect(hdWallet.utxos.length).to.equal(1); expect(hdWallet.utxos[0].address).to.equal(receiveAddress); expect(hdWallet.utxos[0].sats).to.equal(sats); expect(hdWallet.balanceSats).to.equal(sats); expect(result.balanceSatsDelta).to.equal(sats); expect(hdWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Handles mixed token and non-token outputs', () => { const txid = '66'.repeat(32); const sats1 = 546n; // Token UTXO const sats2 = 90_000n; // Non-token UTXO const tokenId = '77'.repeat(32); const atoms = 2000n; const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats: sats1, outputScript: DUMMY_SCRIPT.toHex(), token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms, isMintBaton: false, }, }, { sats: sats2, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); expect(testWallet.utxos.length).to.equal(2); // First UTXO has token, so balance should only include sats2 expect(testWallet.balanceSats).to.equal(sats2); expect(testWallet.utxos[0].token?.tokenId).to.equal(tokenId); expect(testWallet.utxos[1].token).to.equal(undefined); expect(result.balanceSatsDelta).to.equal(sats2); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Handles mint baton token UTXO', () => { const txid = '88'.repeat(32); const sats = 546n; const tokenId = '99'.repeat(32); const tx: ChronikTx = { txid, version: 1, inputs: [], outputs: [ { sats, outputScript: DUMMY_SCRIPT.toHex(), token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 0n, isMintBaton: true, }, }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); expect(testWallet.utxos.length).to.equal(1); expect(testWallet.utxos[0].token?.isMintBaton).to.equal(true); expect(testWallet.utxos[0].token?.atoms).to.equal(0n); // Mint baton is a token UTXO, so balance should not be updated expect(testWallet.balanceSats).to.equal(0n); expect(result.balanceSatsDelta).to.equal(0n); expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); it('Removes spent UTXOs from inputs and returns balance deltas', () => { const initialSats = 200_000n; const receivedSats = 100_000n; // Net delta = received - spent (spent is the full initialSats from the UTXO) const expectedNetDelta = receivedSats - initialSats; // Add an initial UTXO to the wallet const initialTxid = '00'.repeat(32); testWallet.utxos.push({ outpoint: { txid: initialTxid, outIdx: 0 }, blockHeight: DUMMY_TIPHEIGHT, isCoinbase: false, sats: initialSats, isFinal: true, address: DUMMY_ADDRESS, }); testWallet.balanceSats = initialSats; // Create a transaction that spends the initial UTXO and receives a new one const txid = '11'.repeat(32); const tx: ChronikTx = { txid, version: 1, inputs: [ { prevOut: { txid: initialTxid, outIdx: 0, }, inputScript: '', sats: initialSats, sequenceNo: 0xffffffff, }, ], outputs: [ { sats: receivedSats, outputScript: DUMMY_SCRIPT.toHex(), }, { sats: initialSats - receivedSats - 1000n, // Change output (not wallet's) outputScript: OTHER_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = testWallet.addReceivedTx(tx); // Verify the spent UTXO was removed expect(testWallet.utxos.length).to.equal(1); expect( testWallet.utxos.find( utxo => utxo.outpoint.txid === initialTxid && utxo.outpoint.outIdx === 0, ), ).to.equal(undefined); // Verify the new UTXO was added const newUtxo = testWallet.utxos.find( utxo => utxo.outpoint.txid === txid && utxo.outpoint.outIdx === 0, ); expect(newUtxo).to.not.equal(undefined); expect(newUtxo?.sats).to.equal(receivedSats); // Verify balance delta (received - spent) expect(result.balanceSatsDelta).to.equal(expectedNetDelta); expect(testWallet.balanceSats).to.equal( initialSats + expectedNetDelta, ); expect(testWallet.balanceSats).to.equal( initialSats + result.balanceSatsDelta, ); expect(result.tokenDeltas.size).to.equal(0); }); it('Handles token UTXO spending and receiving with balance deltas', () => { const tokenId = 'aa'.repeat(32); const initialAtoms = 5000n; const receivedAtoms = 3000n; // Net delta = received - spent (spent is the full initialAtoms from the UTXO) const expectedTokenDelta = receivedAtoms - initialAtoms; // Add an initial token UTXO const initialTxid = '22'.repeat(32); testWallet.utxos.push({ outpoint: { txid: initialTxid, outIdx: 0 }, blockHeight: DUMMY_TIPHEIGHT, isCoinbase: false, sats: 546n, isFinal: true, address: DUMMY_ADDRESS, token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: initialAtoms, isMintBaton: false, }, }); // Create a transaction that spends the token UTXO and receives a new one const txid = '33'.repeat(32); const tx: ChronikTx = { txid, version: 1, inputs: [ { prevOut: { txid: initialTxid, outIdx: 0, }, inputScript: '', sats: 546n, sequenceNo: 0xffffffff, token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: initialAtoms, // Input shows the full amount from the UTXO isMintBaton: false, }, }, ], outputs: [ { sats: 546n, outputScript: DUMMY_SCRIPT.toHex(), token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: receivedAtoms, isMintBaton: false, }, }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const initialBalance = testWallet.balanceSats; const result = testWallet.addReceivedTx(tx); // Verify token delta (received - spent from UTXO) expect(result.tokenDeltas.get(tokenId)).to.equal( expectedTokenDelta, ); expect(result.balanceSatsDelta).to.equal(0n); // Token UTXOs don't affect sats balance expect(testWallet.balanceSats).to.equal( initialBalance + result.balanceSatsDelta, ); }); }); }); describe('isWalletAddress and isWalletScript', () => { const testMnemonic = 'shift satisfy hammer fit plunge swear athlete gentle tragic sorry blush cheap'; describe('Non-HD wallet', () => { it('isWalletAddress and isWalletScript return true for wallet address/script and false for others', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); // Wallet's own address should be recognized expect( wallet.isWalletAddress(Address.fromCashAddress(wallet.address)), ).to.equal(true); // Wallet's own script should be recognized expect(wallet.isWalletScript(wallet.script)).to.equal(true); // Create a completely different address and script const otherScript = Script.p2pkh( shaRmd160(testEcc.derivePubkey(fromHex('33'.repeat(32)))), ); const otherAddress = Address.fromScript(otherScript); expect(wallet.isWalletScript(otherScript)).to.equal(false); expect(wallet.isWalletAddress(otherAddress)).to.equal(false); }); }); describe('HD wallet', () => { it('isWalletAddress and isWalletScript return true for wallet addresses/scripts and false for others', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); // Wallet's base address should be recognized expect( wallet.isWalletAddress(Address.fromCashAddress(wallet.address)), ).to.equal(true); // Wallet's base script should be recognized expect(wallet.isWalletScript(wallet.script)).to.equal(true); // Additional receive address should be recognized const receiveAddress1 = wallet.getReceiveAddress(1); expect( wallet.isWalletAddress( Address.fromCashAddress(receiveAddress1), ), ).to.equal(true); // Additional receive address script should be recognized expect( wallet.isWalletScript(Script.fromAddress(receiveAddress1)), ).to.equal(true); // Change address should be recognized const changeAddress0 = wallet.getChangeAddress(0); expect( wallet.isWalletAddress(Address.fromCashAddress(changeAddress0)), ).to.equal(true); // Change address script should be recognized expect( wallet.isWalletScript(Script.fromAddress(changeAddress0)), ).to.equal(true); // Create a completely different address and script const otherScript = Script.p2pkh( shaRmd160(testEcc.derivePubkey(fromHex('33'.repeat(32)))), ); const otherAddress = Address.fromScript(otherScript); expect(wallet.isWalletScript(otherScript)).to.equal(false); expect(wallet.isWalletAddress(otherAddress)).to.equal(false); }); }); }); describe('getTxAmounts', () => { const testMnemonic = 'shift satisfy hammer fit plunge swear athlete gentle tragic sorry blush cheap'; const OTHER_SK = fromHex('33'.repeat(32)); const OTHER_PK = testEcc.derivePubkey(OTHER_SK); const OTHER_HASH = shaRmd160(OTHER_PK); const OTHER_SCRIPT = Script.p2pkh(OTHER_HASH); describe('Non-HD wallet', () => { let mockChronik: MockChronikClient; let wallet: Wallet; beforeEach(() => { mockChronik = new MockChronikClient(); wallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, ); }); it('Returns correct amounts for self-send transaction', () => { const tx: ChronikTx = { txid: 'aa'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: DUMMY_SCRIPT.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, }, ], outputs: [ { sats: 9000n, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(true); expect(result.balanceSatsDelta).to.equal(-1000n); // 9000 - 10000 expect(result.tokenDeltas.size).to.equal(0); }); it('Returns correct amounts for transaction sending to external address', () => { const tx: ChronikTx = { txid: 'bb'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: DUMMY_SCRIPT.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, }, ], outputs: [ { sats: 5000n, outputScript: OTHER_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(false); expect(result.balanceSatsDelta).to.equal(-10000n); // Only input counted expect(result.tokenDeltas.size).to.equal(0); }); it('Returns correct amounts for transaction receiving from external', () => { const tx: ChronikTx = { txid: 'cc'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: OTHER_SCRIPT.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, }, ], outputs: [ { sats: 5000n, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(false); expect(result.balanceSatsDelta).to.equal(5000n); // Only output counted expect(result.tokenDeltas.size).to.equal(0); }); it('Returns correct amounts for transaction with tokens', () => { const tokenId = 'dd'.repeat(32); const tx: ChronikTx = { txid: 'ee'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: DUMMY_SCRIPT.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 5000n, isMintBaton: false, }, }, ], outputs: [ { sats: 546n, outputScript: DUMMY_SCRIPT.toHex(), token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 3000n, isMintBaton: false, }, }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(true); expect(result.balanceSatsDelta).to.equal(-9454n); // 546 - 10000 expect(result.tokenDeltas.size).to.equal(1); expect(result.tokenDeltas.get(tokenId)).to.equal(-2000n); // 3000 - 5000 }); it('Returns correct amounts for transaction with no wallet involvement', () => { const tx: ChronikTx = { txid: 'ff'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: OTHER_SCRIPT.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, }, ], outputs: [ { sats: 5000n, outputScript: OTHER_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(false); expect(result.balanceSatsDelta).to.equal(0n); expect(result.tokenDeltas.size).to.equal(0); }); it('Skips coinbase inputs', () => { const tx: ChronikTx = { txid: 'gg'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '00'.repeat(32), outIdx: 0xffffffff, }, inputScript: '', outputScript: undefined, // Coinbase input value: 0n, sequenceNo: 0xffffffff, sats: 0n, }, ], outputs: [ { sats: 5000n, outputScript: DUMMY_SCRIPT.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: true, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(true); expect(result.balanceSatsDelta).to.equal(5000n); // Only output counted expect(result.tokenDeltas.size).to.equal(0); }); }); describe('HD wallet', () => { let mockChronik: MockChronikClient; let wallet: Wallet; beforeEach(() => { mockChronik = new MockChronikClient(); wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); }); it('Returns correct amounts for self-send across HD addresses', () => { const receiveAddress0 = wallet.getReceiveAddress(0); const receiveScript0 = Script.fromAddress(receiveAddress0); const receiveAddress1 = wallet.getReceiveAddress(1); const receiveScript1 = Script.fromAddress(receiveAddress1); const tx: ChronikTx = { txid: 'hh'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: receiveScript0.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, }, ], outputs: [ { sats: 9000n, outputScript: receiveScript1.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(true); expect(result.balanceSatsDelta).to.equal(-1000n); // 9000 - 10000 expect(result.tokenDeltas.size).to.equal(0); }); it('Returns correct amounts for transaction with change address', () => { const receiveAddress0 = wallet.getReceiveAddress(0); const receiveScript0 = Script.fromAddress(receiveAddress0); const changeAddress0 = wallet.getChangeAddress(0); const changeScript0 = Script.fromAddress(changeAddress0); const tx: ChronikTx = { txid: 'ii'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: receiveScript0.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, }, ], outputs: [ { sats: 5000n, outputScript: OTHER_SCRIPT.toHex(), }, { sats: 4000n, outputScript: changeScript0.toHex(), }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(false); // External output expect(result.balanceSatsDelta).to.equal(-6000n); // 4000 - 10000 expect(result.tokenDeltas.size).to.equal(0); }); it('Returns correct amounts for transaction with tokens across HD addresses', () => { const tokenId = 'jj'.repeat(32); const receiveAddress0 = wallet.getReceiveAddress(0); const receiveScript0 = Script.fromAddress(receiveAddress0); const receiveAddress1 = wallet.getReceiveAddress(1); const receiveScript1 = Script.fromAddress(receiveAddress1); const tx: ChronikTx = { txid: 'kk'.repeat(32), version: 1, inputs: [ { prevOut: { txid: '11'.repeat(32), outIdx: 0, }, inputScript: '', outputScript: receiveScript0.toHex(), value: 10000n, sequenceNo: 0xffffffff, sats: 10000n, token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 5000n, isMintBaton: false, }, }, ], outputs: [ { sats: 546n, outputScript: receiveScript1.toHex(), token: { tokenId, tokenType: ALP_TOKEN_TYPE_STANDARD, atoms: 3000n, isMintBaton: false, }, }, ], lockTime: 0, timeFirstSeen: 1234567890, size: 200, isCoinbase: false, tokenEntries: [], tokenFailedParsings: [], tokenStatus: 'TOKEN_STATUS_NON_TOKEN' as TokenStatus, isFinal: true, }; const result = wallet.getTxAmounts(tx); expect(result.selfSend).to.equal(true); expect(result.balanceSatsDelta).to.equal(-9454n); // 546 - 10000 expect(result.tokenDeltas.size).to.equal(1); expect(result.tokenDeltas.get(tokenId)).to.equal(-2000n); // 3000 - 5000 }); }); }); describe('Testnet (ectest prefix) support', () => { const testMnemonic = 'shift satisfy hammer fit plunge swear athlete gentle tragic sorry blush cheap'; const ectestPrefix = 'ectest'; describe('Wallet', () => { it('Wallet.fromMnemonic creates HD wallet with ectest prefix', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, prefix: ectestPrefix }, ); expect(wallet.isHD).to.equal(true); expect(wallet.baseHdNode).to.not.equal(undefined); expect(wallet.accountNumber).to.equal(0); expect(wallet.prefix).to.equal(ectestPrefix); expect(wallet.address).to.include(ectestPrefix); }); it('Wallet.fromMnemonic generates testnet addresses for receive and change addresses', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, prefix: ectestPrefix }, ); const receiveAddr0 = wallet.getReceiveAddress(0); const receiveAddr1 = wallet.getReceiveAddress(1); const changeAddr0 = wallet.getChangeAddress(0); expect(receiveAddr0).to.include(ectestPrefix); expect(receiveAddr1).to.include(ectestPrefix); expect(changeAddr0).to.include(ectestPrefix); expect(wallet.prefix).to.equal(ectestPrefix); }); it('Wallet.fromMnemonic with testnet prefix generates different addresses than mainnet', () => { const mockChronik = new MockChronikClient(); const mainnetWallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 0, prefix: 'ecash' }, ); const testnetWallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true, accountNumber: 0, prefix: ectestPrefix }, ); // Same derivation path, different prefix expect(mainnetWallet.address).to.include('ecash:'); expect(testnetWallet.address).to.include(ectestPrefix); expect(mainnetWallet.address).to.not.equal(testnetWallet.address); // But the addresses should have the same hash (just different prefix) const mainnetAddrObj = Address.fromCashAddress( mainnetWallet.address, ); const testnetAddrObj = Address.fromCashAddress( testnetWallet.address, ); expect(mainnetAddrObj.hash).to.equal(testnetAddrObj.hash); }); it('Wallet.fromSk creates wallet with ectest prefix', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, { prefix: ectestPrefix }, ); expect(wallet.isHD).to.equal(false); expect(wallet.prefix).to.equal(ectestPrefix); expect(wallet.address).to.include(ectestPrefix); }); it('Wallet.fromSk with testnet prefix generates different address than mainnet', () => { const mockChronik = new MockChronikClient(); const mainnetWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, { prefix: 'ecash' }, ); const testnetWallet = Wallet.fromSk( DUMMY_SK, mockChronik as unknown as ChronikClient, { prefix: ectestPrefix }, ); // Same secret key, different prefix expect(mainnetWallet.address).to.include('ecash:'); expect(testnetWallet.address).to.include(ectestPrefix); expect(mainnetWallet.address).to.not.equal(testnetWallet.address); // But the addresses should have the same hash (just different prefix) const mainnetAddrObj = Address.fromCashAddress( mainnetWallet.address, ); const testnetAddrObj = Address.fromCashAddress( testnetWallet.address, ); expect(mainnetAddrObj.hash).to.equal(testnetAddrObj.hash); }); it('Wallet uses ecash prefix by default when prefix not specified', () => { const mockChronik = new MockChronikClient(); const wallet = Wallet.fromMnemonic( testMnemonic, mockChronik as unknown as ChronikClient, { hd: true }, ); expect(wallet.prefix).to.equal('ecash'); expect(wallet.address).to.include('ecash:'); }); }); });