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
text/typescript
// 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: