diff --git a/currency/index.ts b/currency/index.ts index d7df564d..70df0c40 100644 --- a/currency/index.ts +++ b/currency/index.ts @@ -15,20 +15,27 @@ const getStxFiatEquivalent = (stxAmount: BigNumber, stxBtcRate: BigNumber, btcFi const getBtcFiatEquivalent = (btcAmount: BigNumber, btcFiatRate: BigNumber): BigNumber => satsToBtc(btcAmount).multipliedBy(btcFiatRate); +const getFiatBtcEquivalent = (fiatAmount: BigNumber, btcFiatRate: BigNumber): BigNumber => + new BigNumber(fiatAmount.dividedBy(btcFiatRate).toFixed(8)); + const getStxTokenEquivalent = (fiatAmount: BigNumber, stxBtcRate: BigNumber, btcFiatRate: BigNumber): BigNumber => fiatAmount.dividedBy(stxBtcRate).dividedBy(btcFiatRate); +/** + * @deprecated Use getBtcFiatEquivalent instead + */ const getBtcEquivalent = (fiatAmount: BigNumber, btcFiatRate: BigNumber): BigNumber => fiatAmount.dividedBy(btcFiatRate); export { - fetchBtcFeeRate, - satsToBtc, btcToSats, - microstacksToStx, - stxToMicrostacks, - getStxFiatEquivalent, + fetchBtcFeeRate, + getBtcEquivalent, getBtcFiatEquivalent, + getFiatBtcEquivalent, + getStxFiatEquivalent, getStxTokenEquivalent, - getBtcEquivalent, + microstacksToStx, + satsToBtc, + stxToMicrostacks, }; diff --git a/tests/transactions/bitcoin/actionProcessors.sendBtc.test.ts b/tests/transactions/bitcoin/actionProcessors.sendBtc.test.ts index bb0a2fc1..c1e6f7da 100644 --- a/tests/transactions/bitcoin/actionProcessors.sendBtc.test.ts +++ b/tests/transactions/bitcoin/actionProcessors.sendBtc.test.ts @@ -47,6 +47,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [ { type: btcTransaction.ActionType.SEND_BTC, @@ -87,9 +88,9 @@ describe('applySendBtcActionsAndFee', () => { vi.mocked(getTransactionVSize).mockResolvedValueOnce(260); vi.mocked(getTransactionVSize).mockResolvedValueOnce(190); - await expect(() => applySendBtcActionsAndFee(context as any, {}, transaction as any, [], 10)).rejects.toThrowError( - 'No more UTXOs to use. Insufficient funds for this transaction', - ); + await expect(() => + applySendBtcActionsAndFee(context as any, {}, transaction as any, {}, [], 10), + ).rejects.toThrowError('No more UTXOs to use. Insufficient funds for this transaction'); }); it("doesn't alter the transaction if no actions and enough for fees", async () => { @@ -122,6 +123,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [], 10, ); @@ -169,6 +171,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [], 5, ); @@ -220,6 +223,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [], 5, 'overrideChangeAddress', @@ -295,6 +299,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [], 10, ); @@ -376,6 +381,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [], 10, ); @@ -457,6 +463,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [], 10, ); @@ -529,6 +536,7 @@ describe('applySendBtcActionsAndFee', () => { context as any, {}, transaction as any, + {}, [ { type: btcTransaction.ActionType.SEND_BTC, diff --git a/tests/transactions/bitcoin/actionProcessors.sendUtxo.test.ts b/tests/transactions/bitcoin/actionProcessors.sendUtxo.test.ts index 08984e5f..4e9cc49b 100644 --- a/tests/transactions/bitcoin/actionProcessors.sendUtxo.test.ts +++ b/tests/transactions/bitcoin/actionProcessors.sendUtxo.test.ts @@ -6,7 +6,7 @@ import { applySendUtxoActions } from '../../../transactions/bitcoin/actionProces describe('applySendUtxoActions', () => { it('throws if excluded utxo is used for send', async () => { await expect(() => - applySendUtxoActions({} as any, { excludeOutpointList: ['txid:0'] }, { inputsLength: 0 } as any, [ + applySendUtxoActions({} as any, {}, { inputsLength: 0 } as any, { excludeOutpointList: ['txid:0'] }, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'txid:0', @@ -23,7 +23,7 @@ describe('applySendUtxoActions', () => { context.getUtxo.mockResolvedValueOnce({}); await expect(() => - applySendUtxoActions(context as any, {}, { inputsLength: 0 } as any, [ + applySendUtxoActions(context as any, {}, { inputsLength: 0 } as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'txid:0', @@ -48,7 +48,7 @@ describe('applySendUtxoActions', () => { }); await expect(() => - applySendUtxoActions(context as any, {}, transaction as any, [ + applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', @@ -70,7 +70,7 @@ describe('applySendUtxoActions', () => { const transaction = { inputsLength: 0 }; await expect(() => - applySendUtxoActions(context as any, {}, transaction as any, [ + applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', @@ -101,7 +101,7 @@ describe('applySendUtxoActions', () => { const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', @@ -145,7 +145,7 @@ describe('applySendUtxoActions', () => { const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', @@ -195,7 +195,7 @@ describe('applySendUtxoActions', () => { const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', @@ -242,7 +242,7 @@ describe('applySendUtxoActions', () => { const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', @@ -304,7 +304,7 @@ describe('applySendUtxoActions', () => { const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySendUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SEND_UTXO, outpoint: 'f00d:0', diff --git a/tests/transactions/bitcoin/actionProcessors.splitUtxo.test.ts b/tests/transactions/bitcoin/actionProcessors.splitUtxo.test.ts index 311c89e7..0f6d4528 100644 --- a/tests/transactions/bitcoin/actionProcessors.splitUtxo.test.ts +++ b/tests/transactions/bitcoin/actionProcessors.splitUtxo.test.ts @@ -12,7 +12,7 @@ describe('applySplitUtxoActions', () => { }); await expect(() => - applySplitUtxoActions({} as any, {}, transaction as any, [ + applySplitUtxoActions({} as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:0', @@ -28,7 +28,7 @@ describe('applySplitUtxoActions', () => { const transaction = { inputsLength: 0 }; await expect(() => - applySplitUtxoActions({} as any, { excludeOutpointList: ['f00d:0'] }, transaction as any, [ + applySplitUtxoActions({} as any, {}, transaction as any, { excludeOutpointList: ['f00d:0'] }, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:0', @@ -46,7 +46,7 @@ describe('applySplitUtxoActions', () => { const transaction = { inputsLength: 0 }; await expect(() => - applySplitUtxoActions(context as any, {}, transaction as any, [ + applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:0', @@ -65,7 +65,7 @@ describe('applySplitUtxoActions', () => { const transaction = { inputsLength: 0 }; await expect(() => - applySplitUtxoActions(context as any, {}, transaction as any, [ + applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:-1', @@ -84,7 +84,7 @@ describe('applySplitUtxoActions', () => { const transaction = { inputsLength: 0 }; await expect(() => - applySplitUtxoActions(context as any, {}, transaction as any, [ + applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:100', @@ -107,7 +107,7 @@ describe('applySplitUtxoActions', () => { const transaction = { inputsLength: 0 }; await expect(() => - applySplitUtxoActions(context as any, {}, transaction as any, [ + applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:600', @@ -130,7 +130,7 @@ describe('applySplitUtxoActions', () => { }); const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:600', @@ -186,7 +186,7 @@ describe('applySplitUtxoActions', () => { }); const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:600', @@ -265,7 +265,7 @@ describe('applySplitUtxoActions', () => { }); const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:600', @@ -340,7 +340,7 @@ describe('applySplitUtxoActions', () => { }); const transaction = { inputsLength: 0 }; - const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, [ + const { inputs, outputs } = await applySplitUtxoActions(context as any, {}, transaction as any, {}, [ { type: btcTransaction.ActionType.SPLIT_UTXO, location: 'f00d:0:600', diff --git a/tests/transactions/bitcoin/enhancedTransaction.test.ts b/tests/transactions/bitcoin/enhancedTransaction.test.ts index 3baa0c85..91eb118d 100644 --- a/tests/transactions/bitcoin/enhancedTransaction.test.ts +++ b/tests/transactions/bitcoin/enhancedTransaction.test.ts @@ -34,45 +34,6 @@ describe('EnhancedTransaction constructor', () => { expect(() => new EnhancedTransaction(ctx, [], 1)).throws('No actions provided for transaction context'); }); - it('should throw on low fee rate', () => { - const txn = new EnhancedTransaction( - ctx, - [ - { - type: ActionType.SEND_BTC, - amount: 100000n, - combinable: false, - toAddress: addresses[0].nativeSegwit, - }, - ], - 1, - ); - expect(() => (txn.feeRate = 0)).throws('Fee rate must be a natural number'); - expect(() => (txn.feeRate = -1)).throws('Fee rate must be a natural number'); - expect(() => (txn.feeRate = 1)).not.toThrow(); - }); - - it('should round decimal fee rate', () => { - const txn = new EnhancedTransaction( - ctx, - [ - { - type: ActionType.SEND_BTC, - amount: 100000n, - combinable: false, - toAddress: addresses[0].nativeSegwit, - }, - ], - 1, - ); - - txn.feeRate = 1.1; - expect(txn.feeRate).equals(1); - - txn.feeRate = 1.5; - expect(txn.feeRate).equals(2); - }); - describe('should throw if spendable send utxo actions invalid', () => { it.each([ [ @@ -223,7 +184,7 @@ describe('EnhancedTransaction summary', () => { vi.mocked(applySendUtxoActions).mockRejectedValue(new Error('Not enough utxos at desired fee rate')); - await expect(() => txn.getFeeSummary()).rejects.toThrow('Not enough utxos at desired fee rate'); + await expect(() => txn.getSummary()).rejects.toThrow('Not enough utxos at desired fee rate'); }); it('compiles transaction and summary correctly', async () => { @@ -372,11 +333,12 @@ describe('EnhancedTransaction summary', () => { actualFee: 500n, actualFeeRate: 50, effectiveFeeRate: 50, + dustValue: 2n, }); // ========================== // actual thing we're testing - const summary = await txn.getFeeSummary(); + const summary = await txn.getSummary(); // ========================== expect(summary).toEqual({ @@ -509,6 +471,7 @@ describe('EnhancedTransaction summary', () => { }, ], }, + dustValue: 2n, }); expect(paymentAddressContext.signInputs).not.toHaveBeenCalled(); diff --git a/tests/transactions/bitcoin/index.test.ts b/tests/transactions/bitcoin/index.test.ts new file mode 100644 index 00000000..2b68ddff --- /dev/null +++ b/tests/transactions/bitcoin/index.test.ts @@ -0,0 +1,472 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ActionType, combineUtxos, sendBtc, sendMaxBtc, sendOrdinals } from '../../../transactions/bitcoin'; +import { EnhancedTransaction } from '../../../transactions/bitcoin/enhancedTransaction'; +import { addresses } from './helpers'; + +vi.mock('../../../transactions/bitcoin/enhancedTransaction'); + +describe('sendMaxBtc', () => { + const paymentAddress = addresses[0].nestedSegwit; + + const recipientAddress = addresses[0].nativeSegwit; + + const contextMock = { + paymentAddress: { + address: paymentAddress, + getUtxos: vi.fn(), + }, + } as any; + + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + }); + + it('should throw if no UTXOs are available', async () => { + contextMock.paymentAddress.getUtxos.mockResolvedValueOnce([]); + + await expect(sendMaxBtc(contextMock, recipientAddress, 2)).rejects.toThrow('No utxos found'); + }); + + it('should use all UTXOs if dust is not skipped', async () => { + const dummyUtxos = [ + { + outpoint: 'out1', + utxo: { + value: 1000, + }, + }, + { + outpoint: 'out2', + utxo: { + value: 2000, + }, + }, + ]; + contextMock.paymentAddress.getUtxos.mockResolvedValueOnce(dummyUtxos); + + const { transaction, dustFiltered } = await sendMaxBtc(contextMock, recipientAddress, 2, false); + + expect(dustFiltered).toEqual(false); + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out1', + toAddress: recipientAddress, + }, + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out2', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[0]); + }); + + it('should filter UTXOs if dust is skipped', async () => { + const dummyUtxos = [ + { + outpoint: 'out1', + utxo: { + value: 1000, + }, + }, + { + outpoint: 'out2', + utxo: { + value: 2000, + }, + }, + ]; + contextMock.paymentAddress.getUtxos.mockResolvedValueOnce(dummyUtxos); + + vi.mocked(EnhancedTransaction).mockImplementationOnce( + () => + ({ + getSummary: async () => + ({ + dustValue: 1000, + } as any), + } as any), + ); + + const { transaction, dustFiltered } = await sendMaxBtc(contextMock, recipientAddress, 2, true); + + expect(dustFiltered).toEqual(true); + expect(EnhancedTransaction).toHaveBeenCalledTimes(2); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out1', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out2', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[1]); + }); + + it('should not filter UTXOs if all are above dust', async () => { + const dummyUtxos = [ + { + outpoint: 'out1', + utxo: { + value: 1000, + }, + }, + { + outpoint: 'out2', + utxo: { + value: 2000, + }, + }, + ]; + contextMock.paymentAddress.getUtxos.mockResolvedValueOnce(dummyUtxos); + + vi.mocked(EnhancedTransaction).mockImplementationOnce( + () => + ({ + getSummary: async () => + ({ + dustValue: 900, + } as any), + } as any), + ); + + const { transaction, dustFiltered } = await sendMaxBtc(contextMock, recipientAddress, 2, true); + + expect(dustFiltered).toEqual(false); + expect(EnhancedTransaction).toHaveBeenCalledTimes(2); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out1', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out1', + toAddress: recipientAddress, + }, + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out2', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[1]); + }); + + it('should throw if all utxos below dust', async () => { + const dummyUtxos = [ + { + outpoint: 'out1', + utxo: { + value: 1000, + }, + }, + { + outpoint: 'out2', + utxo: { + value: 2000, + }, + }, + ]; + contextMock.paymentAddress.getUtxos.mockResolvedValueOnce(dummyUtxos); + + vi.mocked(EnhancedTransaction).mockImplementationOnce( + () => + ({ + getSummary: async () => + ({ + dustValue: 3000, + } as any), + } as any), + ); + + await expect(() => sendMaxBtc(contextMock, recipientAddress, 2, true)).rejects.toThrow('All UTXOs are dust'); + + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out1', + toAddress: recipientAddress, + }, + ], + 2, + ); + }); +}); + +describe('combineUtxos', () => { + const recipientAddress = addresses[0].nativeSegwit; + + const contextMock = {} as any; + + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + }); + + it('should generate correct transaction - non spendable', async () => { + const dummyOutpoints = ['out1', 'out2']; + const transaction = await combineUtxos(contextMock, dummyOutpoints, recipientAddress, 2); + + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: false, + outpoint: 'out1', + toAddress: recipientAddress, + }, + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: false, + outpoint: 'out2', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[0]); + }); + + it('should generate correct transaction - spendable', async () => { + const dummyOutpoints = ['out1', 'out2']; + const transaction = await combineUtxos(contextMock, dummyOutpoints, recipientAddress, 2, true); + + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out1', + toAddress: recipientAddress, + }, + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: 'out2', + toAddress: recipientAddress, + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[0]); + }); +}); + +describe('sendBtc', () => { + const paymentAddress = addresses[0].nestedSegwit; + const ordinalsAddress = addresses[0].taproot; + + const contextMock = {} as any; + + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + }); + + it('should generate correct transaction', async () => { + const recipients = [ + { + toAddress: paymentAddress, + amount: 10000n, + }, + { + toAddress: ordinalsAddress, + amount: 20000n, + }, + ]; + const transaction = await sendBtc(contextMock, recipients, 2); + + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_BTC, + combinable: false, + toAddress: paymentAddress, + amount: 10000n, + }, + { + type: ActionType.SEND_BTC, + combinable: false, + toAddress: ordinalsAddress, + amount: 20000n, + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[0]); + }); +}); + +describe('sendOrdinals', () => { + const paymentsAddress = addresses[0].nestedSegwit; + const ordinalsAddress = addresses[0].taproot; + + const contextMock = { + getInscriptionUtxo: vi.fn(), + } as any; + + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + }); + + it('should throw if not recipients are provided', async () => { + await expect(sendOrdinals(contextMock, [], 2)).rejects.toThrow('Must provide at least 1 recipient'); + }); + + it('should generate correct transaction with outpoints', async () => { + const recipients = [ + { + toAddress: ordinalsAddress, + outpoint: 'out1', + }, + ]; + const transaction = await sendOrdinals(contextMock, recipients, 2); + + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: false, + spendable: false, + toAddress: ordinalsAddress, + outpoint: 'out1', + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[0]); + }); + + it('should generate correct transaction with inscription ids', async () => { + const recipients = [ + { + toAddress: ordinalsAddress, + outpoint: 'out1', + }, + { + toAddress: paymentsAddress, + inscriptionId: 'i1', + }, + { + toAddress: ordinalsAddress, + inscriptionId: 'i2', + }, + ]; + + contextMock.getInscriptionUtxo.mockImplementation(async (inscriptionId: string) => { + return { + extendedUtxo: { outpoint: `outpoint-${inscriptionId}` }, + }; + }); + + const transaction = await sendOrdinals(contextMock, recipients, 2); + + expect(EnhancedTransaction).toHaveBeenCalledTimes(1); + expect(EnhancedTransaction).toHaveBeenCalledWith( + contextMock, + [ + { + type: ActionType.SEND_UTXO, + combinable: false, + spendable: false, + toAddress: ordinalsAddress, + outpoint: 'out1', + }, + { + type: ActionType.SEND_UTXO, + combinable: false, + spendable: false, + toAddress: paymentsAddress, + outpoint: 'outpoint-i1', + }, + { + type: ActionType.SEND_UTXO, + combinable: false, + spendable: false, + toAddress: ordinalsAddress, + outpoint: 'outpoint-i2', + }, + ], + 2, + ); + expect(transaction).toEqual(vi.mocked(EnhancedTransaction).mock.instances[0]); + }); + + it('should throw if utxo for inscription not found', async () => { + const recipients = [ + { + toAddress: paymentsAddress, + inscriptionId: 'i1', + }, + ]; + + contextMock.getInscriptionUtxo.mockResolvedValueOnce({}); + + await expect(() => sendOrdinals(contextMock, recipients, 2)).rejects.toThrow('No utxo found for inscription'); + }); +}); diff --git a/transactions/bitcoin/actionProcessors.ts b/transactions/bitcoin/actionProcessors.ts index ffbccf43..76a45828 100644 --- a/transactions/bitcoin/actionProcessors.ts +++ b/transactions/bitcoin/actionProcessors.ts @@ -1,6 +1,14 @@ import { Transaction } from '@scure/btc-signer'; import { TransactionContext } from './context'; -import { CompilationOptions, SendBtcAction, SendUtxoAction, SplitUtxoAction, TransactionOutput } from './types'; +import { ExtendedUtxo } from './extendedUtxo'; +import { + CompilationOptions, + SendBtcAction, + SendUtxoAction, + SplitUtxoAction, + TransactionOptions, + TransactionOutput, +} from './types'; import { extractUsedOutpoints, getOffsetFromLocation, @@ -10,7 +18,6 @@ import { getTransactionVSize, getVbytesForIO, } from './utils'; -import { ExtendedUtxo } from './extendedUtxo'; // these are conservative estimates const ESTIMATED_VBYTES_PER_OUTPUT = 30; // actually around 40 @@ -22,6 +29,7 @@ export const applySendUtxoActions = async ( context: TransactionContext, options: CompilationOptions, transaction: Transaction, + transactionOptions: TransactionOptions, actions: SendUtxoAction[], ) => { const usedOutpoints = extractUsedOutpoints(transaction); @@ -50,7 +58,7 @@ export const applySendUtxoActions = async ( let outputAmount = 0; for (const action of actionGroup) { - if (options.excludeOutpointList?.includes(action.outpoint)) { + if (transactionOptions.excludeOutpointList?.includes(action.outpoint)) { throw new Error(`UTXO excluded but used in send UTXO action: ${action.outpoint}`); } @@ -95,6 +103,7 @@ export const applySplitUtxoActions = async ( context: TransactionContext, options: CompilationOptions, transaction: Transaction, + transactionOptions: TransactionOptions, actions: SplitUtxoAction[], ) => { const usedOutpoints = extractUsedOutpoints(transaction); @@ -144,7 +153,7 @@ export const applySplitUtxoActions = async ( for (let oi = 0; oi < outpointActionList.length; oi++) { const [outpoint, outpointActions] = outpointActionList[oi]; - if (options.excludeOutpointList?.includes(outpoint)) { + if (transactionOptions.excludeOutpointList?.includes(outpoint)) { throw new Error(`UTXO excluded but used in split UTXO action: ${outpoint}`); } @@ -205,6 +214,7 @@ export const applySendBtcActionsAndFee = async ( context: TransactionContext, options: CompilationOptions, transaction: Transaction, + transactionOptions: TransactionOptions, actions: SendBtcAction[], feeRate: number, /** @@ -240,7 +250,7 @@ export const applySendBtcActionsAndFee = async ( // get available UTXOs to spend const unusedPaymentUtxos = await getSortedAvailablePaymentUtxos( context, - new Set([...(options.excludeOutpointList ?? []), ...usedOutpoints]), + new Set([...(transactionOptions.excludeOutpointList ?? []), ...usedOutpoints]), ); // add inputs and outputs for the required actions @@ -313,12 +323,15 @@ export const applySendBtcActionsAndFee = async ( if (feeWithChange < currentChange && currentChange - feeWithChange > DUST_VALUE) { // we do one last test to ensure that adding close to the actual change won't increase the fees - const finalVSizeWithChange = await getTransactionVSize( + const vSizeWithActualChange = await getTransactionVSize( context, transaction, overrideChangeAddress ?? context.changeAddress, currentChange - feeWithChange, ); + + const finalVSizeWithChange = Math.max(vSizeWithActualChange ?? vSizeWithChange, vSizeWithChange); + if (finalVSizeWithChange) { actualFee = BigInt((finalVSizeWithChange + unconfirmedVsize) * feeRate - unconfirmedFee); actualFeeRate = Number(actualFee) / finalVSizeWithChange; @@ -355,7 +368,7 @@ export const applySendBtcActionsAndFee = async ( while ( utxoToUse && (inputDustValueAtFeeRate > utxoToUse.utxo.value || - (!options.allowUnconfirmedInput && !utxoToUse.utxo.status.confirmed)) + (!transactionOptions.allowUnconfirmedInput && !utxoToUse.utxo.status.confirmed)) ) { utxoToUse = unusedPaymentUtxos.pop(); } @@ -364,7 +377,7 @@ export const applySendBtcActionsAndFee = async ( throw new Error('No more UTXOs to use. Insufficient funds for this transaction'); } - if (options.useEffectiveFeeRate) { + if (transactionOptions.useEffectiveFeeRate) { const { totalVsize, totalFee } = await utxoToUse.getUnconfirmedUtxoFeeData(); unconfirmedVsize += totalVsize; @@ -378,9 +391,10 @@ export const applySendBtcActionsAndFee = async ( return { actualFeeRate, - effectiveFeeRate: options.useEffectiveFeeRate ? effectiveFeeRate : undefined, + effectiveFeeRate: transactionOptions.useEffectiveFeeRate ? effectiveFeeRate : undefined, actualFee, inputs, outputs, + dustValue: inputDustValueAtFeeRate, }; }; diff --git a/transactions/bitcoin/context.ts b/transactions/bitcoin/context.ts index fdc829bc..d240b447 100644 --- a/transactions/bitcoin/context.ts +++ b/transactions/bitcoin/context.ts @@ -198,6 +198,37 @@ export abstract class AddressContext { // this can be implemented by subclasses if they need to do something before signing } + protected async addNonWitnessUtxosToInputs( + transaction: btc.Transaction, + options: SignOptions, + witnessScript?: Uint8Array, + ): Promise { + const signIndexes = this.getSignIndexes(transaction, options, witnessScript); + + for (const i of Object.keys(signIndexes)) { + const input = transaction.getInput(+i); + if (!input.txid?.length) { + continue; + } + + const txId = hex.encode(input.txid); + + let utxo = await this.getUtxo(`${txId}:${input.index}`); + + if (!utxo) { + utxo = await this.getExternalUtxo(`${txId}:${input.index}`); + } + + if (utxo) { + const nonWitnessUtxo = Buffer.from(await utxo.hex, 'hex'); + transaction.updateInput(+i, { + nonWitnessUtxo, + // witnessUtxo: undefined, // TODO: this should be removed for non-segwit inputs + }); + } + } + } + protected abstract getDerivationPath(): string; abstract addInput(transaction: btc.Transaction, utxo: ExtendedUtxo, options?: CompilationOptions): Promise; abstract signInputs(transaction: btc.Transaction, options: SignOptions): Promise; @@ -227,7 +258,6 @@ export class P2shAddressContext extends AddressContext { async addInput(transaction: btc.Transaction, extendedUtxo: ExtendedUtxo, options?: CompilationOptions) { const utxo = extendedUtxo.utxo; - const nonWitnessUtxo = Buffer.from(await extendedUtxo.hex, 'hex'); transaction.addInput({ txid: utxo.txid, @@ -238,7 +268,6 @@ export class P2shAddressContext extends AddressContext { }, redeemScript: this._p2sh.redeemScript, witnessScript: this._p2sh.witnessScript, - nonWitnessUtxo, sequence: options?.rbfEnabled ? 0xfffffffd : 0xffffffff, }); } @@ -301,7 +330,6 @@ export class P2wpkhAddressContext extends AddressContext { async addInput(transaction: btc.Transaction, extendedUtxo: ExtendedUtxo, options?: CompilationOptions) { const utxo = extendedUtxo.utxo; - const nonWitnessUtxo = Buffer.from(await extendedUtxo.hex, 'hex'); transaction.addInput({ txid: utxo.txid, @@ -310,7 +338,6 @@ export class P2wpkhAddressContext extends AddressContext { script: this._p2wpkh.script, amount: BigInt(utxo.value), }, - nonWitnessUtxo, sequence: options?.rbfEnabled ? 0xfffffffd : 0xffffffff, }); } @@ -356,6 +383,8 @@ export class LedgerP2wpkhAddressContext extends P2wpkhAddressContext { throw new Error('Transport is required for Ledger signing'); } + await this.addNonWitnessUtxosToInputs(transaction, options, this._p2wpkh.script); + const app = new AppClient(ledgerTransport); const masterFingerPrint = await app.getMasterFingerprint(); @@ -370,6 +399,11 @@ export class LedgerP2wpkhAddressContext extends P2wpkhAddressContext { const signIndexes = this.getSignIndexes(transaction, options, this._p2wpkh.script); for (const i of Object.keys(signIndexes)) { + const input = transaction.getInput(+i); + if (input.bip32Derivation?.some((derivation) => areByteArraysEqual(derivation[0], inputDerivation[0]))) { + continue; + } + transaction.updateInput(+i, { bip32Derivation: [inputDerivation], }); @@ -443,7 +477,6 @@ export class P2trAddressContext extends AddressContext { async addInput(transaction: btc.Transaction, extendedUtxo: ExtendedUtxo, options?: CompilationOptions) { const utxo = extendedUtxo.utxo; - const nonWitnessUtxo = Buffer.from(await extendedUtxo.hex, 'hex'); transaction.addInput({ txid: utxo.txid, @@ -453,7 +486,6 @@ export class P2trAddressContext extends AddressContext { amount: BigInt(utxo.value), }, tapInternalKey: hex.decode(this._publicKey), - nonWitnessUtxo, sequence: options?.rbfEnabled ? 0xfffffffd : 0xffffffff, }); } @@ -497,7 +529,6 @@ export class P2trAddressContext extends AddressContext { export class LedgerP2trAddressContext extends P2trAddressContext { async addInput(transaction: btc.Transaction, extendedUtxo: ExtendedUtxo, options?: CompilationOptions) { const utxo = extendedUtxo.utxo; - const nonWitnessUtxo = Buffer.from(await extendedUtxo.hex, 'hex'); transaction.addInput({ txid: utxo.txid, @@ -506,7 +537,6 @@ export class LedgerP2trAddressContext extends P2trAddressContext { script: this._p2tr.script, amount: BigInt(utxo.value), }, - nonWitnessUtxo, tapInternalKey: this._p2tr.tapInternalKey, sequence: options?.rbfEnabled ? 0xfffffffd : 0xffffffff, }); @@ -518,6 +548,8 @@ export class LedgerP2trAddressContext extends P2trAddressContext { throw new Error('Transport is required for Ledger signing'); } + await this.addNonWitnessUtxosToInputs(transaction, options, this._p2tr.script); + const app = new AppClient(ledgerTransport); const masterFingerPrint = await app.getMasterFingerprint(); @@ -544,6 +576,11 @@ export class LedgerP2trAddressContext extends P2trAddressContext { const signIndexes = this.getSignIndexes(transaction, options, this._p2tr.script); for (const i of Object.keys(signIndexes)) { + const input = transaction.getInput(+i); + if (input.bip32Derivation?.some((derivation) => areByteArraysEqual(derivation[0], inputDerivation[0]))) { + continue; + } + transaction.updateInput(+i, { tapBip32Derivation: [inputDerivation], }); diff --git a/transactions/bitcoin/enhancedTransaction.ts b/transactions/bitcoin/enhancedTransaction.ts index 66ef6fe0..4d77b9c3 100644 --- a/transactions/bitcoin/enhancedTransaction.ts +++ b/transactions/bitcoin/enhancedTransaction.ts @@ -11,19 +11,24 @@ import { CompilationOptions, EnhancedInput, TransactionFeeOutput, + TransactionOptions, TransactionOutput, + TransactionSummary, } from './types'; import { extractActionMap, extractOutputInscriptionsAndSatributes, mapInputToEnhancedInput } from './utils'; -const defaultOptions: CompilationOptions = { +const defaultCompilationOptions: CompilationOptions = { rbfEnabled: false, - excludeOutpointList: [], - useEffectiveFeeRate: false, - allowUnconfirmedInput: true, }; const getOptionsWithDefaults = (options: CompilationOptions): CompilationOptions => { - return { ...defaultOptions, ...options }; + return { ...defaultCompilationOptions, ...options }; +}; + +const defaultTransactionOptions: TransactionOptions = { + excludeOutpointList: [], + useEffectiveFeeRate: false, + allowUnconfirmedInput: true, }; export class EnhancedTransaction { @@ -31,10 +36,12 @@ export class EnhancedTransaction { private readonly _actions!: ActionMap; - private _feeRate!: number; + private readonly _feeRate!: number; private readonly _overrideChangeAddress?: string; + private readonly _options!: TransactionOptions; + get overrideChangeAddress(): string | undefined { return this._overrideChangeAddress; } @@ -43,16 +50,14 @@ export class EnhancedTransaction { return this._feeRate; } - set feeRate(feeRate: number) { - if (feeRate < 1) { - throw new Error('Fee rate must be a natural number'); - } - this._feeRate = Math.round(feeRate); + get options(): TransactionOptions { + return { ...this._options, excludeOutpointList: [...(this._options.excludeOutpointList ?? [])] }; } - constructor(context: TransactionContext, actions: Action[], feeRate: number) { + constructor(context: TransactionContext, actions: Action[], feeRate: number, options?: TransactionOptions) { this._context = context; this._feeRate = feeRate; + this._options = { ...defaultTransactionOptions, ...options }; if (!actions.length) { throw new Error('No actions provided for transaction context'); @@ -88,6 +93,7 @@ export class EnhancedTransaction { this._context, options, transaction, + this._options, this._actions[ActionType.SEND_UTXO], ); @@ -95,6 +101,7 @@ export class EnhancedTransaction { this._context, options, transaction, + this._options, this._actions[ActionType.SPLIT_UTXO], ); @@ -104,10 +111,12 @@ export class EnhancedTransaction { effectiveFeeRate, inputs: sendBtcInputs, outputs: sendBtcOutputs, + dustValue, } = await applySendBtcActionsAndFee( this._context, options, transaction, + this._options, this._actions[ActionType.SEND_BTC], this._feeRate, this._overrideChangeAddress, @@ -162,14 +171,13 @@ export class EnhancedTransaction { inputs: enhancedInputs, outputs, feeOutput: feeOutput as TransactionFeeOutput, + dustValue, }; } - async getFeeSummary(options: CompilationOptions = {}) { - const { actualFee, actualFeeRate, effectiveFeeRate, transaction, inputs, outputs, feeOutput } = await this.compile( - getOptionsWithDefaults(options), - true, - ); + async getSummary(options: CompilationOptions = {}): Promise { + const { actualFee, actualFeeRate, effectiveFeeRate, transaction, inputs, outputs, feeOutput, dustValue } = + await this.compile(getOptionsWithDefaults(options), true); const vsize = transaction.vsize; @@ -181,6 +189,7 @@ export class EnhancedTransaction { inputs, outputs, feeOutput, + dustValue, }; return feeSummary; diff --git a/transactions/bitcoin/index.ts b/transactions/bitcoin/index.ts index a016ce7e..1d140ac9 100644 --- a/transactions/bitcoin/index.ts +++ b/transactions/bitcoin/index.ts @@ -13,8 +13,10 @@ import { SendUtxoAction, SplitUtxoAction, TransactionFeeOutput, + TransactionOptions, TransactionOutput, TransactionScriptOutput, + TransactionSummary, } from './types'; const SPLIT_UTXO_MIN_VALUE = 1500; // the minimum value for a UTXO to be split @@ -30,15 +32,49 @@ export type { SendUtxoAction, SplitUtxoAction, TransactionFeeOutput, + TransactionOptions, TransactionOutput, TransactionScriptOutput, + TransactionSummary, }; /** * send max bitcoin */ -export const sendMaxBtc = async (context: TransactionContext, toAddress: string, feeRate: number) => { - const paymentUtxos = await context.paymentAddress.getUtxos(); +export const sendMaxBtc = async (context: TransactionContext, toAddress: string, feeRate: number, skipDust = true) => { + let paymentUtxos = await context.paymentAddress.getUtxos(); + let dustFiltered = false; + + if (paymentUtxos.length === 0) { + throw new Error('No utxos found'); + } + + if (skipDust) { + const testTransaction = new EnhancedTransaction( + context, + [ + { + type: ActionType.SEND_UTXO, + combinable: true, + spendable: true, + outpoint: paymentUtxos[0].outpoint, + toAddress, + }, + ], + feeRate, + ); + + const { dustValue } = await testTransaction.getSummary(); + + const filteredPaymentUtxos = paymentUtxos.filter((utxo) => utxo.utxo.value > dustValue); + dustFiltered = filteredPaymentUtxos.length !== paymentUtxos.length; + paymentUtxos = filteredPaymentUtxos; + + if (paymentUtxos.length === 0) { + throw new Error('All UTXOs are dust'); + } + } + const actions = paymentUtxos.map((utxo) => ({ type: ActionType.SEND_UTXO, combinable: true, @@ -46,8 +82,9 @@ export const sendMaxBtc = async (context: TransactionContext, toAddress: string, outpoint: utxo.outpoint, toAddress, })); + const transaction = new EnhancedTransaction(context, actions, feeRate); - return transaction; + return { transaction, dustFiltered }; }; /** @@ -58,12 +95,12 @@ export const combineUtxos = async ( outpoints: string[], toAddress: string, feeRate: number, - spendable?: boolean, + spendable = false, ) => { const actions = outpoints.map((outpoint) => ({ type: ActionType.SEND_UTXO, combinable: true, - spendable: spendable ?? false, + spendable, outpoint, toAddress, })); @@ -144,6 +181,7 @@ export const sendOrdinals = async ( }; /** + * @deprecated Not deprecated, but in beta. Needs tests. Do not use until tested. * send inscription * send multiple inscription to 1 recipient * send multiple inscription to multiple recipients @@ -317,6 +355,9 @@ export const sendOrdinalsWithSplit = async ( return transaction; }; +/** + * @deprecated Not deprecated, but in beta. Needs tests. Do not use until tested. + **/ export const extractOrdinalsFromUtxo = async (context: TransactionContext, outpoint: string, feeRate: number) => { const utxo = await context.getUtxo(outpoint); @@ -338,6 +379,9 @@ export const extractOrdinalsFromUtxo = async (context: TransactionContext, outpo return sendOrdinalsWithSplit(context, recipients, feeRate); }; +/** + * @deprecated Not deprecated, but in beta. Needs tests. Do not use until tested. + **/ export const recoverBitcoin = async (context: TransactionContext, feeRate: number, outpoint?: string) => { if (context.paymentAddress.address === context.ordinalsAddress.address) { throw new Error('Cannot recover bitcoin to same address'); @@ -383,6 +427,9 @@ export const recoverBitcoin = async (context: TransactionContext, feeRate: numbe return transaction; }; +/** + * @deprecated Not deprecated, but in beta. Needs tests. Do not use until tested. + **/ export const recoverOrdinal = async (context: TransactionContext, feeRate: number, outpoint?: string) => { if (context.paymentAddress.address === context.ordinalsAddress.address) { throw new Error('Cannot recover ordinals to same address'); diff --git a/transactions/bitcoin/types.ts b/transactions/bitcoin/types.ts index 4d6c193b..7f7c73fe 100644 --- a/transactions/bitcoin/types.ts +++ b/transactions/bitcoin/types.ts @@ -53,14 +53,28 @@ export type ActionMap = { [K in Action['type']]: Action[]; }; -export type CompilationOptions = { - rbfEnabled?: boolean; - ledgerTransport?: Transport; +export type TransactionOptions = { excludeOutpointList?: string[]; useEffectiveFeeRate?: boolean; allowUnconfirmedInput?: boolean; }; +export type CompilationOptions = { + rbfEnabled?: boolean; + ledgerTransport?: Transport; +}; + +export type TransactionSummary = { + fee: bigint; + feeRate: number; + effectiveFeeRate: number | undefined; + vsize: number; + inputs: EnhancedInput[]; + outputs: TransactionOutput[]; + feeOutput: TransactionFeeOutput; + dustValue: bigint; +}; + export type PSBTCompilationOptions = { ledgerTransport?: Transport; finalize?: boolean;