diff --git a/package.json b/package.json index ae751c7a..e490b7cf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bech32": "^2.0.0", "bignumber.js": "^9.1.2", "bitcoinjs-lib": "^6.1.6", + "coinselect": "^3.1.13", "cosmjs-types": "^0.9.0", "hi-base32": "^0.5.1", "js-sha512": "^0.8.0", @@ -52,5 +53,10 @@ "ts-node": "^10.4.0", "tsconfig-paths": "^4.2.0", "typescript": "^5" + }, + "pnpm": { + "patchedDependencies": { + "coinselect@3.1.13": "patches/coinselect@3.1.13.patch" + } } } diff --git a/patches/coinselect@3.1.13.patch b/patches/coinselect@3.1.13.patch new file mode 100644 index 00000000..048dc161 --- /dev/null +++ b/patches/coinselect@3.1.13.patch @@ -0,0 +1,66 @@ +diff --git a/index.d.ts b/index.d.ts +new file mode 100644 +index 0000000..20cc98a +--- /dev/null ++++ b/index.d.ts +@@ -0,0 +1,48 @@ ++// Declare the main coinselect module ++declare module "coinselect" { ++ export interface UTXO { ++ txid: string | Buffer; ++ vout: number; ++ value: number; ++ nonWitnessUtxo?: Buffer; ++ witnessUtxo?: { ++ script: Buffer; ++ value: number; ++ }; ++ } ++ ++ export type Target = ++ | { ++ address: string; ++ value: number; ++ } ++ | { ++ address: string; ++ } ++ | { ++ value: number; ++ }; ++ ++ export interface SelectedUTXO { ++ inputs?: UTXO[]; ++ outputs?: Target[]; ++ fee: number; ++ } ++ ++ export default function coinSelect( ++ utxos: UTXO[], ++ outputs: Target[], ++ feeRate: number ++ ): SelectedUTXO; ++} ++ ++// Declare the coinselect/split module ++declare module "coinselect/split.js" { ++ import { UTXO, Target, SelectedUTXO } from "coinselect"; ++ ++ export default function split( ++ utxos: UTXO[], ++ outputs: Target[], ++ feeRate: number ++ ): SelectedUTXO; ++} +diff --git a/package.json b/package.json +index 6cdef00..337bf4f 100644 +--- a/package.json ++++ b/package.json +@@ -31,6 +31,7 @@ + "utils.js" + ], + "main": "index.js", ++ "types": "index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/bitcoinjs/coinselect.git" diff --git a/patches/how_to.md b/patches/how_to.md new file mode 100644 index 00000000..1df0b48b --- /dev/null +++ b/patches/how_to.md @@ -0,0 +1,40 @@ +# Why this patch + +The library `coinselect.js` is manipulating important and low level objects, it seems important to make sure we don't just send any information to it. +Because the lib is only published in NPM without its types ([because the maintainer doesnt trust NPM](https://github.com/bitcoinjs/coinselect/pull/77#issuecomment-1676430774)), I've patched the lib using their own type definition [their own definitions](https://github.com/bitcoinjs/coinselect/blob/master/index.d.ts), modified to actually reflect the lib's bahaviour. + + +# How to maintain the patch + +Create a temporary copy of the library to patch +```bash +pnpm patch +``` + +You now have access to a similar folder as: `/private/var/folders/yn/k6gkp3pd7fq4dn71mw1yb8qh0000gn/T/5191b8b733d6bbfd169476c53b6a123b` that I'll refer to as `temp_lib` + +Open it on vscode +```bash +code "/temp_lib" +``` + +Do all the changes that you need. +And then run +``` +diff -Naur node_modules/.pnpm/coinselect@3.1.13/node_modules/coinselect/ /temp_lib +``` + +And copy the changes to `patches/coinselect@3.1.13.patch`, just taking the lines with `+/-` preceeded by the ones with `@@`. + +You can now your patch with +```bash +pnpm install --force +``` + +If all is good, `patches/coinselect@3.1.13.patch` can be commited as any other file. + +# Why use `diff` manually and not use `pmpm patch-commit temp_lib` ? + +For some reason, it seems pnpm 8 doesn't handle well newly created files (and not just modified ones). This should be better in [pnpm 9](https://github.com/pnpm/pnpm/issues/5686#issuecomment-2272406668) + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e949e22b..27cd9030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + coinselect@3.1.13: + hash: rgmirwghbjfxxpvt2uxigomtpq + path: patches/coinselect@3.1.13.patch + dependencies: '@cosmjs/amino': specifier: ^0.32.2 @@ -44,6 +49,9 @@ dependencies: bitcoinjs-lib: specifier: ^6.1.6 version: 6.1.6 + coinselect: + specifier: ^3.1.13 + version: 3.1.13(patch_hash=rgmirwghbjfxxpvt2uxigomtpq) cosmjs-types: specifier: ^0.9.0 version: 0.9.0 @@ -2077,6 +2085,11 @@ packages: engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} dev: true + /coinselect@3.1.13(patch_hash=rgmirwghbjfxxpvt2uxigomtpq): + resolution: {integrity: sha512-iJOrKH/7N9gX0jRkxgOHuGjvzvoxUMSeylDhH1sHn+CjLjdin5R0Hz2WEBu/jrZV5OrHcm+6DMzxwu9zb5mSZg==} + dev: false + patched: true + /collect-v8-coverage@1.0.2: resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} dev: true diff --git a/src/app/api/schemaCommon.ts b/src/app/api/schemaCommon.ts index 6d6c45e8..20fddf86 100644 --- a/src/app/api/schemaCommon.ts +++ b/src/app/api/schemaCommon.ts @@ -62,7 +62,7 @@ const positiveBigintSchema = z .or(toNumber) .transform((data) => BigInt(data)) .openapi({ - type: "string" + type: "string", }); const recipientSchema = z.object({ @@ -211,7 +211,7 @@ const transactionDataSchema = z case ChainFeature.TRANSACTIONS_TO_MANY: return [TransactionMode.TRANSFER_TO_MANY]; default: - return []; // filter out features that don't concern transac + return []; // filter out features that don't concern transactions } }); // idea of how we could have generic errors for features, for all chains @@ -223,40 +223,7 @@ const transactionDataSchema = z options: supportedTransactionModes, }; } - ) - // async validation of addresses - .superRefine(async (data, ctx) => { - if (data.mode === TransactionMode.TRANSFER_TO_MANY) { - if (data.chainId != ChainId.BITCOIN && data.chainId != ChainId.BITCOIN_TESTNET) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `TransferToMany is not supported for ${data.chainId}`, - fatal: true, - }); - return z.NEVER; - } - - const validSender = await getService(data.chainId).validateAddress(data.sender); - if (!validSender) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Sender address ${data.sender} is invalid for ${data.chainId}`, - path: ["sender"], - }); - } - for (const recipient of data.recipients) { - const validRecipient = await getService(data.chainId).validateAddress(recipient.address); - if (!validRecipient) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Recipient address ${recipient.address} is invalid for ${data.chainId}`, - path: ["recipients", "address"], - }); - } - } - } - // TODO: add all of prevalidateTransactionCommon here? - }); + ); const errorMsgSchema = z.object({ message: z.string(), diff --git a/src/app/api/transaction/txSchemaCommon.ts b/src/app/api/transaction/txSchemaCommon.ts index 70565c7c..4def740a 100644 --- a/src/app/api/transaction/txSchemaCommon.ts +++ b/src/app/api/transaction/txSchemaCommon.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { transactionDataSchema } from "../schemaCommon"; -import { SchemaObject } from "node_modules/zod-openapi/lib-types/openapi3-ts/dist/oas31"; const newTransactionSchema = z .object({ diff --git a/src/app/config/chains.ts b/src/app/config/chains.ts index 8640d08f..32282836 100644 --- a/src/app/config/chains.ts +++ b/src/app/config/chains.ts @@ -29,6 +29,7 @@ const FEATURES: { [key in ChainFamily]: ChainFeature[] } = { [ChainFamily.BITCOIN]: [ ChainFeature.BALANCES_NATIVE, ChainFeature.TRANSACTIONS_TO_MANY, + ChainFeature.TRANSACTIONS_NATIVE, ], }; @@ -421,10 +422,10 @@ const CHAINS: { [key in ChainId]: Chain } = { nodeURL: "", explorerURL: "https://api.blockchair.com/bitcoin", }, -params: { - confirmations: 6, -}, -nativeId: "bitcoin", + params: { + confirmations: 6, + }, + nativeId: "bitcoin", supportedFeatures: FEATURES[ChainFamily.BITCOIN], }, [ChainId.BITCOIN_TESTNET]: { diff --git a/src/app/families/bitcoin/backend/explorer.ts b/src/app/families/bitcoin/backend/explorer.ts index 758db349..36dbf794 100644 --- a/src/app/families/bitcoin/backend/explorer.ts +++ b/src/app/families/bitcoin/backend/explorer.ts @@ -4,8 +4,8 @@ import { ChainId } from "~/app/model/types_WIP"; import { Xpub } from "../types"; import { BlockchairAddressesResponse, - TransactionBroadcastStatusSchema, BlockchairXpubResponse, + TransactionBroadcastStatusSchema, } from "./types"; const queryParams = new URLSearchParams({ @@ -50,7 +50,7 @@ export async function fetchAddressesDashboard(chainId: ChainId, addresses: strin return result.data; } -export async function postTransaction(chaindId: ChainId, signedTransaction: string) { +export async function postTransaction(chaindId: ChainId, signedTransaction: string): Promise { const explorerURL = CHAINS[chaindId].backends.explorerURL; queryParams.set("data", signedTransaction); @@ -69,3 +69,74 @@ export async function postTransaction(chaindId: ChainId, signedTransaction: stri return result.data.data.transaction_hash; } + +export async function fetchFeePerByte(chainId: ChainId): Promise { + const explorerURL = CHAINS[chainId].backends.explorerURL; + + const url = `/stats?${queryParams.toString()}`; + + const response = await fetch(explorerURL + url); + + // TODO: add zod validation + const responseBody = await response.json(); + + if (responseBody.context.code !== 200) { + throw new Error(`fetchNetworkInformations - received invalid response: ${responseBody.context.error}`); + } + + return responseBody.data.suggested_transaction_fee_per_byte_sat; +} + +export async function fetchTxMetadata( + chainId: ChainId, + txHash: string +): Promise<{ + [key: string]: { + has_witness: boolean; + outputs: [ + { + index: number; + script_hex: string; + // ... other fields are not used for now + } + ]; + // ... other fields are not used for now + }; +}> { + const explorerURL = CHAINS[chainId].backends.explorerURL; + const url = `/dashboards/transaction/${txHash}?${queryParams.toString()}`; + + const response = await fetch(explorerURL + url); + + const responseBody = await response.json(); + + if (responseBody.context.code !== 200) { + throw new Error(`fetchRawTxMetadata - received invalid response: ${responseBody.context.error}`); + } + + return responseBody.data; +} + +export async function fetchRawTxMetadata( + chainId: ChainId, + txHash: string +): Promise<{ + [key: string]: { + // key is tx_hash + raw_transaction: string; + /// ... other fields are not used for now + }; +}> { + const explorerURL = CHAINS[chainId].backends.explorerURL; + const url = `/raw/transaction/${txHash}?${queryParams.toString()}`; + + const response = await fetch(explorerURL + url); + + const responseBody = await response.json(); + + if (responseBody.context.code !== 200) { + throw new Error(`fetchRawTxMetadata - received invalid response: ${responseBody.context.error}`); + } + + return responseBody.data; +} diff --git a/src/app/families/bitcoin/backend/types.ts b/src/app/families/bitcoin/backend/types.ts index 50550a7f..037974f7 100644 --- a/src/app/families/bitcoin/backend/types.ts +++ b/src/app/families/bitcoin/backend/types.ts @@ -1,15 +1,26 @@ +import { UTXO } from "coinselect"; import z from "zod"; -// Blockchair responses SUB-MODELS -const UtxosSchema = z.object({ - block_id: z.number(), - transaction_hash: z.string(), - index: z.number(), - value: z.number(), - address: z.string(), -}); +const BlockchairUtxoData = z + .object({ + block_id: z.number(), + transaction_hash: z.string(), + index: z.number(), + value: z.number(), + address: z.string(), + }) + .transform((data) => { + return { + /** add fields for compatibility with coinselect @see {@link UTXO} */ + txid: data.transaction_hash, + vout: data.index, + ...data, + }; + }); -const AddressSchema = z.object({ +export type UtxoWithMetadata = z.infer; + +const BlockchairAddressData = z.object({ type: z.string(), script_hex: z.string(), balance: z.number(), @@ -209,14 +220,14 @@ export const BlockchairXpubData = z.record( }), addresses: z.record( z.string(), - AddressSchema.and( + BlockchairAddressData.and( z.object({ path: z.string(), }) ) ), transactions: z.array(z.string()), - utxo: UtxosSchema.array(), + utxo: BlockchairUtxoData.array(), }) ); @@ -232,15 +243,15 @@ export const BlockchairXpubResponse = z.object({ export const BlockchairAddressesResponse = z.object({ data: z.object({ - set: AddressSchema.omit({ + set: BlockchairAddressData.omit({ type: true, script_hex: true, received_usd: true, spent_usd: true, }), - addresses: z.record(z.string(), AddressSchema), + addresses: z.record(z.string(), BlockchairAddressData), transactions: z.array(z.string()), - utxo: UtxosSchema.and( + utxo: BlockchairUtxoData.and( z.object({ address: z.string(), }) @@ -272,8 +283,10 @@ export const BlockchairAddressesResponse = z.object({ * */ export const TransactionBroadcastStatusSchema = z.object({ - data: z.object({ - transaction_hash: z.string(), - }).optional(), + data: z + .object({ + transaction_hash: z.string(), + }) + .optional(), context: requestContextSchema, -}) +}); diff --git a/src/app/families/bitcoin/service/completeTransaction.ts b/src/app/families/bitcoin/service/completeTransaction.ts index dce0f7d8..28a6b75d 100644 --- a/src/app/families/bitcoin/service/completeTransaction.ts +++ b/src/app/families/bitcoin/service/completeTransaction.ts @@ -1,11 +1,97 @@ -import { Transaction, TransactionWrapper } from "~/app/model/types_WIP"; +import coinSelect from "coinselect"; +import split from "coinselect/split.js"; +import { isMultiRecipientTransaction, TransactionMode, TransactionWrapper } from "~/app/model/types_WIP"; import { BtcService } from "."; +import { fetchFeePerByte, fetchXpubDashboard } from "../backend/explorer"; +import { UtxoWithMetadata } from "../backend/types"; +import { Xpub } from "../types"; -async function completeTransaction( - this: BtcService, - transaction: TransactionWrapper -): Promise { - throw new Error("Not implemented"); +async function completeTransaction(this: BtcService, transaction: TransactionWrapper): Promise { + let selectionAlgorithm = coinSelect; + + if (!isMultiRecipientTransaction(transaction.data)) { + if (transaction.data.mode !== TransactionMode.TRANSFER) { + transaction.status.errors.push({ + message: `Invalid transaction mode: ${transaction.data.mode} for chainId ${transaction.data.chainId}`, + }); + return; + } + + if (!transaction.data.useMaxAmount === !transaction.data.amount) { + transaction.status.errors.push({ + message: `Either one of the field "amount" or "userMaxAmount" is missing, or both are mistakenly set together for ${transaction.data.chainId}`, + }); + return; + } + + transaction.data = { + chainId: transaction.data.chainId, + mode: TransactionMode.TRANSFER_TO_MANY, + sender: transaction.data.senders[0] as Xpub, + recipients: [ + { + address: transaction.data.recipients[0], + amount: transaction.data.useMaxAmount ? undefined : transaction.data.amount, + }, + ], + inputs: [], + fees: 0n, + }; + selectionAlgorithm = split; + } + + const feeRate = await fetchFeePerByte(this.chainId); + + const allUtxos = (await fetchXpubDashboard(this.chainId, transaction.data.sender)).data[ + transaction.data.sender + ].utxo; + + const { inputs, outputs, fee } = selectionAlgorithm( + allUtxos, + transaction.data.recipients.map((recipient) => { + return { + address: recipient.address, + value: recipient.amount ? Number(recipient.amount) : undefined, + }; + }), + feeRate + ); + + // .inputs and .outputs will be undefined if no solution was found + if (!inputs || !outputs) { + transaction.status.errors.push({ + message: + "No solution found during UTXO selection, you might not have enough funds or an account that's too fragmented.", + }); + return; + } + + transaction.data.inputs = []; + for (const input of inputs) { + transaction.data.inputs.push(input as UtxoWithMetadata); + } + + for (const out of outputs) { + // if address is not defined, it means it's a newly added recipient by coinselect (the change) that we need to reflect in the transaction + if (!("address" in out)) { + transaction.data.recipients.push({ + // Take the address of the first input, its as good as any other one + // FIXME: terrible pratice, we should derive a new change address from the Xpub + address: (inputs[0] as UtxoWithMetadata).address, + amount: BigInt(out.value), + }); + } else { + // if there is a value in the output, and there was no amount in the original recipient + // => it means we are doing a spendAll transfer and should copy the calculated amount to the recipient + const recipientNeedingAmount = transaction.data.recipients.find( + (r) => r.address === out.address && !r.amount + ); + if (recipientNeedingAmount && "value" in out) { + recipientNeedingAmount.amount = BigInt(out.value); + } + } + } + transaction.data.fees = BigInt(fee); } export { completeTransaction }; diff --git a/src/app/families/bitcoin/service/encodeTransaction.ts b/src/app/families/bitcoin/service/encodeTransaction.ts index c8bcf06e..f33d841f 100644 --- a/src/app/families/bitcoin/service/encodeTransaction.ts +++ b/src/app/families/bitcoin/service/encodeTransaction.ts @@ -1,16 +1,75 @@ -import { TransactionWrapper } from "~/app/model/types_WIP"; +import { Psbt } from "bitcoinjs-lib"; +import { isMultiRecipientTransaction, TransactionWrapper } from "~/app/model/types_WIP"; import { BtcService } from "."; +import { fetchRawTxMetadata, fetchTxMetadata } from "../backend/explorer"; async function encodeTransactionToSign(this: BtcService, transaction: TransactionWrapper): Promise { - throw new Error("Not implemented"); + /** + * This should never happen because the transaction is already validated by zod + * @see {@link transactionDataSchema.refine} at the {@link transactionDataSchema} definition + * TODO: maybe we could make sure that completeTransaction directly receives a multi-recipient transaction ? + */ + if (!isMultiRecipientTransaction(transaction.data)) { + throw new Error(`Something went wrong, the transaction is not a multi-recipient transaction.`); + } + + if (!transaction.data.inputs || transaction.data.inputs.length === 0) { + // should not happen as the transaction is already passed through completeTransaction + throw new Error(`Something went wrong, the transaction has no inputs.`); + } + + let psbt = new Psbt(); + + // Enrich inputs from the explorer + for (let inputIndex = 0; inputIndex < transaction.data.inputs.length; inputIndex++) { + const input = transaction.data.inputs[inputIndex]; + const txMetadata = await fetchTxMetadata(this.chainId, input.transaction_hash); + + // FIXME: did I missunderstood the has_witness field? + // TODO: Add link to documentation here + if (!txMetadata[input.transaction_hash].has_witness) { + psbt.addInput({ + ...input, + hash: input.transaction_hash, + witnessUtxo: { + script: Buffer.from(txMetadata[input.transaction_hash].outputs[input.index].script_hex, "hex"), + value: input.value, + }, + }); + } else { + const nonWitnessUtxo = (await fetchRawTxMetadata(this.chainId, input.transaction_hash))[ + input.transaction_hash + ].raw_transaction; + + psbt.addInput({ + ...input, + hash: input.transaction_hash, + nonWitnessUtxo: Buffer.from(nonWitnessUtxo, "hex"), + }); + } + // activates RBF: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki#summary + // 0xfffffffd is stricly smaller than 0xffffffff - 1 + psbt.setInputSequence(inputIndex, 0xfffffffd); + } + + for (const recipient of transaction.data.recipients) { + psbt.addOutput({ + address: recipient.address, + value: Number(recipient.amount), + }); + } + + transaction.encoded = psbt.toHex(); } async function encodeTransactionToBroadcast( this: BtcService, signedTransaction: TransactionWrapper ): Promise { - throw new Error("Not implemented"); + if (!signedTransaction.signature) { + throw new Error("missing transaction signature"); + } + return signedTransaction.signature; } export { encodeTransactionToBroadcast, encodeTransactionToSign }; - diff --git a/src/app/families/bitcoin/service/validateTransaction.ts b/src/app/families/bitcoin/service/validateTransaction.ts index 6bbfe6b7..953c05b9 100644 --- a/src/app/families/bitcoin/service/validateTransaction.ts +++ b/src/app/families/bitcoin/service/validateTransaction.ts @@ -1,8 +1,48 @@ -import { TransactionWrapper } from "~/app/model/types_WIP"; +import { TransactionMode, TransactionWrapper } from "~/app/model/types_WIP"; import { BtcService } from "."; +import { isValidAddress, isValidXpub } from "../logic"; async function prevalidateTransaction(this: BtcService, transaction: TransactionWrapper): Promise { - // does nothing, everything is already done by zod schema before even reaching the controller + switch (transaction.data.mode) { + case TransactionMode.TRANSFER: + if (!isValidXpub(transaction.data.senders[0])) { + transaction.status.errors.push({ + message: `Sender address ${transaction.data.senders[0]} is invalid for ${transaction.data.chainId}`, + }); + } + if (!isValidAddress(transaction.data.recipients[0], this.chainId)) { + transaction.status.errors.push({ + message: `Recipient address ${transaction.data.recipients[0]} is invalid for ${transaction.data.chainId}`, + }); + } + // for now, we only support useMaxAmount for TRANSFER. Once Transfer to many is merged with Transfer, we can remove this check + if (!transaction.data.useMaxAmount) { + transaction.status.errors.push({ + message: `Missing field useMaxAmount for ${transaction.data.chainId}`, + }); + } + break; + case TransactionMode.TRANSFER_TO_MANY: + if (!isValidXpub(transaction.data.sender)) { + transaction.status.errors.push({ + message: `Sender address ${transaction.data.sender} is invalid for ${transaction.data.chainId}`, + }); + } + for (const recipient of transaction.data.recipients) { + if (!isValidAddress(recipient.address, this.chainId)) { + transaction.status.errors.push({ + message: `Recipient address ${recipient.address} is invalid for ${transaction.data.chainId}`, + }); + } + } + break; + default: + // should never happen, already checked by zod using defined supported features + transaction.status.errors.push({ + message: `Invalid transaction mode: ${transaction.data.mode} for chainId ${transaction.data.chainId}`, + }); + return; + } } async function validateTransaction(this: BtcService, transaction: TransactionWrapper): Promise { diff --git a/src/app/model/types_WIP.ts b/src/app/model/types_WIP.ts index f0f848c6..da1e7672 100644 --- a/src/app/model/types_WIP.ts +++ b/src/app/model/types_WIP.ts @@ -1,6 +1,7 @@ import { BtcParams, Xpub } from "../families/bitcoin/types"; import { CosmosChainParams, CosmosTransactionParams } from "../families/cosmos/types"; import { EvmChainParams } from "../families/evm/types"; +import { UtxoWithMetadata } from "../families/bitcoin/backend/types"; enum ChainId { ALGORAND = "algorand", @@ -194,7 +195,7 @@ enum TransactionMode { } type Recipient = { - amount: bigint; + amount?: bigint; address: string; }; @@ -224,6 +225,7 @@ type CommonTransaction = { memo?: string; format?: EncodedTransactionFormat; params?: TransactionParams; + inputs?: UtxoWithMetadata[]; // for internal BTC use only }; type TransferTransaction = BaseTransaction & @@ -247,9 +249,14 @@ type MultiRecipientTransaction = BaseTransaction & { mode: TransactionMode.TRANSFER_TO_MANY; sender: Xpub; recipients: Recipient[]; + inputs?: UtxoWithMetadata[]; fees: bigint; }; +function isMultiRecipientTransaction(transaction: TransactionData): transaction is MultiRecipientTransaction { + return transaction.mode === TransactionMode.TRANSFER_TO_MANY; +} + type UndelegateTransaction = BaseTransaction & CommonTransaction & { mode: TransactionMode.UNDELEGATE; @@ -353,6 +360,7 @@ export { StakingStatus, TokenType, TransactionMode, + isMultiRecipientTransaction, }; export type {