UNPKG

ecash-wallet

Version:

An ecash wallet class. Manage keys, build and broadcast txs. Includes support for tokens and agora.

1,383 lines (1,317 loc) 255 kB
// 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, } from 'ecash-lib'; import { OutPoint, ScriptUtxo, ChronikClient, Token, TokenType, } from 'chronik-client'; import { MockChronikClient } from 'mock-chronik-client'; import { Wallet, validateTokenActions, getActionTotals, getTokenType, finalizeOutputs, selectUtxos, SatsSelectionStrategy, paymentOutputsToTxOutputs, getNftChildGenesisInput, getUtxoFromOutput, } from './wallet'; 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: ScriptUtxo = { outpoint: DUMMY_OUTPOINT, blockHeight: DUMMY_TIPHEIGHT, isCoinbase: false, sats: 546n, isFinal: true, }; /** * 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: ScriptUtxo = { ...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: ScriptUtxo = { ...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: ScriptUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 3 }, token: DUMMY_TOKEN_ALP_TOKEN_TYPE_STANDARD, }; const DUMMY_TOKEN_UTXO_ALP_TOKEN_TYPE_STANDARD_MINTBATON: ScriptUtxo = { ...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: ScriptUtxo = { ...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: ScriptUtxo = { ...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: ScriptUtxo = { ...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: ScriptUtxo = { ...DUMMY_UTXO, outpoint: { ...DUMMY_OUTPOINT, outIdx: 5 }, token: DUMMY_TOKEN_SLP_TOKEN_TYPE_MINT_VAULT, }; const DUMMY_SPENDABLE_COINBASE_UTXO_TOKEN: ScriptUtxo = { ...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: ScriptUtxo[] = [ 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([]); // 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, ]); // Now we have utxos expect(testWallet.utxos).to.deep.equal(ALL_SUPPORTED_UTXOS); // 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 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('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); }); }); 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: ScriptUtxo[] = []; expect(selectUtxos(action, spendableUtxos)).to.deep.equal({ success: false, missingSats: 1000n, requiresTxChain: false, errors: [ 'Insufficient sats to complete tx. Need 1000 additional satoshis to complete this Action.', ], }); }); 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: ScriptUtxo[] = []; expect( selectUtxos( action, spendableUtxos, SatsSelectionStrategy.NO_SATS, ), ).to.deep.equal({ success: true, missingSats: 1000n, utxos: [], requiresTxChain: false, }); }); 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, SatsSelectionStrategy.ATTEMPT_SATS, ), ).to.deep.equal({ success: true, missingSats: 200n, utxos: [ { ...DUMMY_UTXO, sats: 500n }, { ...DUMMY_UTXO, sats: 300n }, ], requiresTxChain: false, }); }); 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, SatsSelectionStrategy.ATTEMPT_SATS, ), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [ { ...DUMMY_UTXO, sats: 500n }, { ...DUMMY_UTXO, sats: 600n }, ], requiresTxChain: false, }); }); 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, SatsSelectionStrategy.ATTEMPT_SATS, ), ).to.deep.equal({ success: true, missingSats: 500n, utxos: [{ ...DUMMY_UTXO, sats: 500n }, getDummySlpUtxo(2n)], requiresTxChain: false, }); }); 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, 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', }, ], ]), requiresTxChain: false, 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, SatsSelectionStrategy.ATTEMPT_SATS, ), ).to.deep.equal({ success: true, missingSats: 0n, utxos: [{ ...DUMMY_UTXO, sats: 1_500n }, getDummySlpUtxo(2n)], requiresTxChain: false, }); }); 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 }, ], requiresTxChain: false, }); }); 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 }], requiresTxChain: false, }); }); 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', }, ], ]), requiresTxChain: false, 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: