diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 20daac7be6..7d6da1fea5 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -200,8 +200,8 @@ export function isWalletOutput(output: Output): output is FixedScriptWalletOutpu ); } -export interface TransactionExplanation extends BaseTransactionExplanation { - locktime: number; +export interface TransactionExplanation extends BaseTransactionExplanation { + locktime?: number; /** NOTE: this actually only captures external outputs */ outputs: Output[]; changeOutputs: Output[]; @@ -872,7 +872,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin { */ async explainTransaction( params: ExplainTransactionOptions - ): Promise { + ): Promise> { return explainTx(this.decodeTransactionFromPrebuild(params), params, this.network); } diff --git a/modules/abstract-utxo/src/impl/doge/doge.ts b/modules/abstract-utxo/src/impl/doge/doge.ts index 551dec78b6..3a7d1e394b 100644 --- a/modules/abstract-utxo/src/impl/doge/doge.ts +++ b/modules/abstract-utxo/src/impl/doge/doge.ts @@ -114,7 +114,7 @@ export class Doge extends AbstractUtxoCoin { async explainTransaction( params: ExplainTransactionOptions | (ExplainTransactionOptions & { txInfo: TransactionInfoJSON }) - ): Promise { + ): Promise> { return super.explainTransaction({ ...params, txInfo: params.txInfo ? parseTransactionInfo(params.txInfo as TransactionInfoJSON) : undefined, diff --git a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts index ebea0d53bc..8e7996b645 100644 --- a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts +++ b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts @@ -8,18 +8,18 @@ export interface ExplanationOutput { amount: string | number; } -export interface TransactionExplanation { +export interface TransactionExplanation { outputs: ExplanationOutput[]; changeOutputs: ExplanationOutput[]; fee: { /* network fee */ - fee: string | number; + fee: TFee; payGoFeeString: string | number | undefined; payGoFeeAddress: string | undefined; }; } -export function getTransactionExplanation(coin: string, tx: unknown): TransactionExplanation { +export function getTransactionExplanation(coin: string, tx: unknown): TransactionExplanation { if (!OfflineVaultSignable.is(tx)) { throw new Error('not a signable transaction'); } diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts index 98e5225a61..a62795cd37 100644 --- a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts +++ b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts @@ -45,7 +45,7 @@ export function getHalfSignedPsbt( export function getTransactionExplanationFromPsbt( tx: DescriptorTransaction, network: utxolib.Network -): TransactionExplanation { +): TransactionExplanation { const psbt = utxolib.bitgo.createPsbtDecode(tx.coinSpecific.txHex, network); const descriptorMap = getDescriptorsFromDescriptorTransaction(tx); const { outputs, changeOutputs, fee } = explainPsbt(psbt, descriptorMap); diff --git a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts index 9fa7af1865..58e2257c34 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts @@ -34,7 +34,7 @@ function getInputSignatures(psbt: utxolib.bitgo.UtxoPsbt): number[] { export function explainPsbt( psbt: utxolib.bitgo.UtxoPsbt, descriptors: coreDescriptors.DescriptorMap -): TransactionExplanation { +): TransactionExplanation { const parsedTransaction = coreDescriptors.parse(psbt, descriptors, psbt.network); const { inputs, outputs } = parsedTransaction; const externalOutputs = outputs.filter((o) => o.scriptId === undefined); diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index 54c943bee2..bf7663cc44 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -22,7 +22,7 @@ export function explainTx( changeInfo?: fixedScript.ChangeAddressInfo[]; }, network: utxolib.Network -): TransactionExplanation { +): TransactionExplanation { if (params.wallet && isDescriptorWallet(params.wallet)) { if (tx instanceof utxolib.bitgo.UtxoPsbt) { if (!params.pubs || !isTriple(params.pubs)) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts index ac568931f7..d78d7627cb 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/explainTransaction.ts @@ -92,7 +92,10 @@ function explainCommon( return { displayOrder, id: tx.getId(), ...outputDetails, fee, locktime }; } -function getRootWalletKeys(params: { pubs?: string[] }) { +function getRootWalletKeys(params: { pubs?: bitgo.RootWalletKeys | string[] }): bitgo.RootWalletKeys | undefined { + if (params.pubs instanceof bitgo.RootWalletKeys) { + return params.pubs; + } const keys = params.pubs?.map((xpub) => bip32.fromBase58(xpub)); return keys && keys.length === 3 ? new bitgo.RootWalletKeys(keys as Triple) : undefined; } @@ -100,7 +103,7 @@ function getRootWalletKeys(params: { pubs?: string[] }) { function getPsbtInputSignaturesCount( psbt: bitgo.UtxoPsbt, params: { - pubs?: string[]; + pubs?: bitgo.RootWalletKeys | string[]; } ) { const rootWalletKeys = getRootWalletKeys(params); @@ -113,7 +116,7 @@ function getTxInputSignaturesCount( tx: bitgo.UtxoTransaction, params: { txInfo?: { unspents?: bitgo.Unspent[] }; - pubs?: string[]; + pubs?: bitgo.RootWalletKeys | string[]; }, network: utxolib.Network ) { @@ -142,162 +145,165 @@ function getTxInputSignaturesCount( }); } -/** - * Decompose a raw psbt into useful information, such as the total amounts, - * change amounts, and transaction outputs. - */ -export function explainPsbt>( - psbt: bitgo.UtxoPsbt, - params: { - pubs?: string[]; - txInfo?: { unspents?: bitgo.Unspent[] }; - }, - network: utxolib.Network, - { strict = false }: { strict?: boolean } = {} -): TransactionExplanation { - const txOutputs = psbt.txOutputs; - const txInputs = psbt.txInputs; +function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) { + const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined; + if (!derivations) { + return undefined; + } + const paths = derivations.map((d) => d.path); + if (!paths || paths.length !== 3) { + throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation'); + } + if (!paths.every((p) => paths[0] === p)) { + throw new Error('expected all paths to be the same'); + } - function getChainAndIndexFromBip32Derivations(output: bitgo.PsbtOutput) { - const derivations = output.bip32Derivation ?? output.tapBip32Derivation ?? undefined; - if (!derivations) { - return undefined; - } - const paths = derivations.map((d) => d.path); - if (!paths || paths.length !== 3) { - throw new Error('expected 3 paths in bip32Derivation or tapBip32Derivation'); - } - if (!paths.every((p) => paths[0] === p)) { - throw new Error('expected all paths to be the same'); + paths.forEach((path) => { + if (paths[0] !== path) { + throw new Error( + 'Unable to get a single chain and index on the output because there are different paths for different keys' + ); } + }); + return utxolib.bitgo.getChainAndIndexFromPath(paths[0]); +} - paths.forEach((path) => { - if (paths[0] !== path) { - throw new Error( - 'Unable to get a single chain and index on the output because there are different paths for different keys' - ); +function getChangeInfo(psbt: bitgo.UtxoPsbt): ChangeAddressInfo[] | undefined { + try { + return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => { + const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]); + if (!derivationInformation) { + throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation'); } + return { + address: utxolib.address.fromOutputScript(psbt.txOutputs[i].script, psbt.network), + external: false, + ...derivationInformation, + }; }); - return utxolib.bitgo.getChainAndIndexFromPath(paths[0]); + } catch (e) { + if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) { + return undefined; + } + throw e; } +} - function getChangeInfo() { - try { - return utxolib.bitgo.findInternalOutputIndices(psbt).map((i) => { - const derivationInformation = getChainAndIndexFromBip32Derivations(psbt.data.outputs[i]); - if (!derivationInformation) { - throw new Error('could not find derivation information on bip32Derivation or tapBip32Derivation'); - } - return { - address: utxolib.address.fromOutputScript(txOutputs[i].script, network), - external: false, - ...derivationInformation, - }; - }); - } catch (e) { - if (e instanceof utxolib.bitgo.ErrorNoMultiSigInputFound) { - return undefined; - } - throw e; - } +/** + * Extract PayGo address proof information from the PSBT if present + * @returns Information about the PayGo proof, including the output index and address + */ +function getPayGoVerificationInfo( + psbt: bitgo.UtxoPsbt, + network: utxolib.Network +): { outputIndex: number; verificationPubkey: string } | undefined { + let outputIndex: number | undefined = undefined; + let address: string | undefined = undefined; + // Check if this PSBT has any PayGo address proofs + if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) { + return undefined; } - /** - * Extract PayGo address proof information from the PSBT if present - * @returns Information about the PayGo proof, including the output index and address - */ - function getPayGoVerificationInfo(): { outputIndex: number; verificationPubkey: string } | undefined { - let outputIndex: number | undefined = undefined; - let address: string | undefined = undefined; - // Check if this PSBT has any PayGo address proofs - if (!utxocore.paygo.psbtOutputIncludesPaygoAddressProof(psbt)) { - return undefined; - } + // This pulls the pubkey depending on given network + const verificationPubkey = getPayGoVerificationPubkey(network); + // find which output index that contains the PayGo proof + outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt); + if (outputIndex === undefined || !verificationPubkey) { + return undefined; + } + const output = psbt.txOutputs[outputIndex]; + address = utxolib.address.fromOutputScript(output.script, network); + if (!address) { + throw new Error(`Can not derive address ${address} Pay Go Attestation.`); + } - // This pulls the pubkey depending on given network - const verificationPubkey = getPayGoVerificationPubkey(network); - // find which output index that contains the PayGo proof - outputIndex = utxocore.paygo.getPayGoAddressProofOutputIndex(psbt); - if (outputIndex === undefined || !verificationPubkey) { - return undefined; - } - const output = txOutputs[outputIndex]; - address = utxolib.address.fromOutputScript(output.script, network); - if (!address) { - throw new Error(`Can not derive address ${address} Pay Go Attestation.`); - } + return { outputIndex, verificationPubkey }; +} - return { outputIndex, verificationPubkey }; - } +/** + * Extract the BIP322 messages and addresses from the PSBT inputs and perform + * verification on the transaction to ensure that it meets the BIP322 requirements. + * @returns An array of objects containing the message and address for each input, + * or undefined if no BIP322 messages are found. + */ +function getBip322MessageInfoAndVerify(psbt: bitgo.UtxoPsbt, network: utxolib.Network): Bip322Message[] | undefined { + const bip322Messages: { message: string; address: string }[] = []; + for (let i = 0; i < psbt.data.inputs.length; i++) { + const message = bip322.getBip322ProofMessageAtIndex(psbt, i); + if (message) { + const input = psbt.data.inputs[i]; + if (!input.witnessUtxo) { + throw new Error(`Missing witnessUtxo for input index ${i}`); + } + if (!input.nonWitnessUtxo) { + throw new Error(`Missing nonWitnessUtxo for input index ${i}`); + } + const scriptPubKey = input.witnessUtxo.script; - /** - * Extract the BIP322 messages and addresses from the PSBT inputs and perform - * verification on the transaction to ensure that it meets the BIP322 requirements. - * @returns An array of objects containing the message and address for each input, - * or undefined if no BIP322 messages are found. - */ - function getBip322MessageInfoAndVerify(): Bip322Message[] | undefined { - const bip322Messages: { message: string; address: string }[] = []; - for (let i = 0; i < psbt.data.inputs.length; i++) { - const message = bip322.getBip322ProofMessageAtIndex(psbt, i); - if (message) { - const input = psbt.data.inputs[i]; - if (!input.witnessUtxo) { - throw new Error(`Missing witnessUtxo for input index ${i}`); - } - if (!input.nonWitnessUtxo) { - throw new Error(`Missing nonWitnessUtxo for input index ${i}`); - } - const scriptPubKey = input.witnessUtxo.script; - - // Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo - const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message); - const toSpendB64 = toSpend.toBuffer().toString('base64'); - if (input.nonWitnessUtxo.toString('base64') !== toSpendB64) { - throw new Error(`Non-witness UTXO does not match the expected toSpend transaction at input index ${i}`); - } - - // Verify that the toSpend transaction ID matches the input's referenced transaction ID - if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(txInputs[i]).txid) { - throw new Error(`ToSpend transaction ID does not match the input at index ${i}`); - } - - // Verify the input specifics - if (txInputs[i].sequence !== 0) { - throw new Error(`Unexpected sequence number at input index ${i}: ${txInputs[i].sequence}. Expected 0.`); - } - if (txInputs[i].index !== 0) { - throw new Error(`Unexpected input index at position ${i}: ${txInputs[i].index}. Expected 0.`); - } - - bip322Messages.push({ - message: message.toString('utf8'), - address: utxolib.address.fromOutputScript(scriptPubKey, network), - }); + // Verify that the toSpend transaction can be recreated in the PSBT and is encoded correctly in the nonWitnessUtxo + const toSpend = bip322.buildToSpendTransaction(scriptPubKey, message); + const toSpendB64 = toSpend.toBuffer().toString('base64'); + if (input.nonWitnessUtxo.toString('base64') !== toSpendB64) { + throw new Error(`Non-witness UTXO does not match the expected toSpend transaction at input index ${i}`); } - } - if (bip322Messages.length > 0) { - // If there is a BIP322 message in any input, all inputs must have one. - if (bip322Messages.length !== psbt.data.inputs.length) { - throw new Error('Inconsistent BIP322 messages across inputs.'); + // Verify that the toSpend transaction ID matches the input's referenced transaction ID + if (toSpend.getId() !== utxolib.bitgo.getOutputIdForInput(psbt.txInputs[i]).txid) { + throw new Error(`ToSpend transaction ID does not match the input at index ${i}`); } - // Verify the transaction specifics for BIP322 - if (psbt.version !== 0 && psbt.version !== 2) { - throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `); + // Verify the input specifics + if (psbt.txInputs[i].sequence !== 0) { + throw new Error(`Unexpected sequence number at input index ${i}: ${psbt.txInputs[i].sequence}. Expected 0.`); } - if (psbt.data.outputs.length !== 1 || txOutputs[0].script.toString('hex') !== '6a' || txOutputs[0].value !== 0n) { - throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`); + if (psbt.txInputs[i].index !== 0) { + throw new Error(`Unexpected input index at position ${i}: ${psbt.txInputs[i].index}. Expected 0.`); } - return bip322Messages; + bip322Messages.push({ + message: message.toString('utf8'), + address: utxolib.address.fromOutputScript(scriptPubKey, network), + }); } + } - return undefined; + if (bip322Messages.length > 0) { + // If there is a BIP322 message in any input, all inputs must have one. + if (bip322Messages.length !== psbt.data.inputs.length) { + throw new Error('Inconsistent BIP322 messages across inputs.'); + } + + // Verify the transaction specifics for BIP322 + if (psbt.version !== 0 && psbt.version !== 2) { + throw new Error(`Unsupported PSBT version for BIP322: ${psbt.version}. Expected 0 `); + } + if ( + psbt.data.outputs.length !== 1 || + psbt.txOutputs[0].script.toString('hex') !== '6a' || + psbt.txOutputs[0].value !== 0n + ) { + throw new Error(`Invalid PSBT outputs for BIP322. Expected exactly one OP_RETURN output with zero value.`); + } + + return bip322Messages; } - const payGoVerificationInfo = getPayGoVerificationInfo(); + return undefined; +} + +/** + * Decompose a raw psbt into useful information, such as the total amounts, + * change amounts, and transaction outputs. + */ +export function explainPsbt( + psbt: bitgo.UtxoPsbt, + params: { + pubs?: bitgo.RootWalletKeys | string[]; + }, + network: utxolib.Network, + { strict = false }: { strict?: boolean } = {} +): TransactionExplanation { + const payGoVerificationInfo = getPayGoVerificationInfo(psbt, network); if (payGoVerificationInfo) { try { utxocore.paygo.verifyPayGoAddressProof( @@ -313,14 +319,14 @@ export function explainPsbt; + const messages = getBip322MessageInfoAndVerify(psbt, network); + const changeInfo = getChangeInfo(psbt); + const tx = psbt.getUnsignedTx(); const common = explainCommon(tx, { ...params, changeInfo }, network); const inputSignaturesCount = getPsbtInputSignaturesCount(psbt, params); // Set fee from subtracting inputs from outputs - const outputAmount = txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0)); + const outputAmount = psbt.txOutputs.reduce((cumulative, curr) => cumulative + BigInt(curr.value), BigInt(0)); const inputAmount = psbt.txInputs.reduce((cumulative, txInput, i) => { const data = psbt.data.inputs[i]; if (data.witnessUtxo) { @@ -339,7 +345,7 @@ export function explainPsbt (curr > prev ? curr : prev), 0), messages, - } as TransactionExplanation; + }; } export function explainLegacyTx( @@ -350,12 +356,12 @@ export function explainLegacyTx( changeInfo?: { address: string; chain: number; index: number }[]; }, network: utxolib.Network -): TransactionExplanation { +): TransactionExplanation { const common = explainCommon(tx, params, network); const inputSignaturesCount = getTxInputSignaturesCount(tx, params, network); return { ...common, inputSignatures: inputSignaturesCount, signatures: inputSignaturesCount.reduce((prev, curr) => (curr > prev ? curr : prev), 0), - } as TransactionExplanation; + }; } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index d2294bc3ff..024ed86fa1 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -53,7 +53,7 @@ export async function parseTransaction( } // obtain all outputs - const explanation: TransactionExplanation = await coin.explainTransaction({ + const explanation: TransactionExplanation = await coin.explainTransaction({ txHex: txPrebuild.txHex, txInfo: txPrebuild.txInfo, pubs: keychainArray.map((k) => k.pub) as Triple, diff --git a/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts new file mode 100644 index 0000000000..4b9697a4bb --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/fixedScript/explainPsbt.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; + +import { testutil } from '@bitgo/utxo-lib'; + +import { explainPsbt } from '../../../../src/transaction/fixedScript'; + +function describeTransactionWith(acidTest: testutil.AcidTest) { + describe(`explainPsbt ${acidTest.name}`, function () { + it('should explain the transaction', function () { + const psbt = acidTest.createPsbt(); + const explanation = explainPsbt(psbt, { pubs: acidTest.rootWalletKeys }, acidTest.network, { strict: true }); + assert.strictEqual(explanation.outputs.length, 3); + assert.strictEqual(explanation.outputAmount, '2700'); + assert.strictEqual(explanation.changeOutputs.length, acidTest.outputs.length - 3); + explanation.changeOutputs.forEach((change) => { + assert.strictEqual(change.amount, '900'); + assert.strictEqual(typeof change.address, 'string'); + }); + assert.strictEqual(explanation.inputSignatures.length, acidTest.inputs.length); + explanation.inputSignatures.forEach((signature, i) => { + if (acidTest.inputs[i].scriptType === 'p2shP2pk') { + return; + } + if (acidTest.signStage === 'unsigned') { + assert.strictEqual(signature, 0); + } else if (acidTest.signStage === 'halfsigned') { + assert.strictEqual(signature, 1); + } else if (acidTest.signStage === 'fullsigned') { + assert.strictEqual(signature, 2); + } + }); + }); + }); +} + +testutil.AcidTest.suite().forEach((test) => describeTransactionWith(test)); diff --git a/modules/utxo-lib/src/testutil/psbt.ts b/modules/utxo-lib/src/testutil/psbt.ts index 8ff877f4b7..23fe79033f 100644 --- a/modules/utxo-lib/src/testutil/psbt.ts +++ b/modules/utxo-lib/src/testutil/psbt.ts @@ -3,6 +3,7 @@ import { ok as assert } from 'assert'; import { createOutputScriptP2shP2pk, + isSupportedScriptType, ScriptType, ScriptType2Of3, scriptTypeP2shP2pk, @@ -26,10 +27,12 @@ import { UtxoTransaction, verifySignatureWithUnspent, addXpubsToPsbt, + clonePsbtWithoutNonWitnessUtxo, } from '../bitgo'; -import { Network } from '../networks'; +import { getNetworkList, getNetworkName, isMainnet, Network, networks } from '../networks'; import { mockReplayProtectionUnspent, mockWalletUnspent } from './mock'; import { toOutputScript } from '../address'; +import { getDefaultWalletKeys, getWalletKeysForSeed } from './keys'; /** * This is a bit of a misnomer, as it actually specifies the spend type of the input. @@ -48,6 +51,9 @@ export type Input = { value: bigint; }; +export const signStages = ['unsigned', 'halfsigned', 'fullsigned'] as const; +export type SignStage = (typeof signStages)[number]; + /** * Set isInternalAddress=true for internal output address */ @@ -169,7 +175,7 @@ export function constructPsbt( outputs: Output[], network: Network, rootWalletKeys: RootWalletKeys, - sign: 'unsigned' | 'halfsigned' | 'fullsigned', + signStage: SignStage, params?: { signers?: { signerName: KeyName; cosignerName?: KeyName }; deterministic?: boolean; @@ -183,6 +189,11 @@ export function constructPsbt( assert(totalInputAmount >= outputInputAmount, 'total output can not exceed total input'); const psbt = createPsbtForNetwork({ network }); + + if (params?.addGlobalXPubs) { + addXpubsToPsbt(psbt, rootWalletKeys); + } + const unspents = inputs.map((input, i) => toUnspent(input, i, network, rootWalletKeys)); unspents.forEach((u, i) => { @@ -226,7 +237,7 @@ export function constructPsbt( throw new Error('invalid output'); }); - if (sign === 'unsigned') { + if (signStage === 'unsigned') { return psbt; } @@ -237,15 +248,106 @@ export function constructPsbt( signAllPsbtInputs(psbt, inputs, rootWalletKeys, 'halfsigned', { signers, skipNonWitnessUtxo }); - if (sign === 'fullsigned') { - signAllPsbtInputs(psbt, inputs, rootWalletKeys, sign, { signers, deterministic, skipNonWitnessUtxo }); + if (signStage === 'fullsigned') { + signAllPsbtInputs(psbt, inputs, rootWalletKeys, signStage, { signers, deterministic, skipNonWitnessUtxo }); } - if (params?.addGlobalXPubs) { - addXpubsToPsbt(psbt, rootWalletKeys); + return psbt; +} + +export const txFormats = ['psbt', 'psbt-lite'] as const; +export type TxFormat = (typeof txFormats)[number]; + +/** + * Creates a valid PSBT with as many features as possible. + * + * - Inputs: + * - All wallet script types that are supported by the network. + * - A p2shP2pk input (for replay protection) + * - Outputs: + * - All wallet script types that are supported by the network. + * - A p2sh output with derivation info of a different wallet (not in the global psbt xpubs) + * - A p2sh output with no derivation info (external output) + * - An OP_RETURN output + */ +export class AcidTest { + public readonly network: Network; + public readonly signStage: SignStage; + public readonly txFormat: TxFormat; + public readonly rootWalletKeys: RootWalletKeys; + public readonly otherWalletKeys: RootWalletKeys; + public readonly inputs: Input[]; + public readonly outputs: Output[]; + + constructor( + network: Network, + signStage: SignStage, + txFormat: TxFormat, + rootWalletKeys: RootWalletKeys, + otherWalletKeys: RootWalletKeys, + inputs: Input[], + outputs: Output[] + ) { + this.network = network; + this.signStage = signStage; + this.txFormat = txFormat; + this.rootWalletKeys = rootWalletKeys; + this.otherWalletKeys = otherWalletKeys; + this.inputs = inputs; + this.outputs = outputs; } - return psbt; + static withDefaults(network: Network, signStage: SignStage, txFormat: TxFormat): AcidTest { + const rootWalletKeys = getDefaultWalletKeys(); + + const otherWalletKeys = getWalletKeysForSeed('too many secrets'); + const inputs: Input[] = inputScriptTypes + .filter((scriptType) => + scriptType === 'taprootKeyPathSpend' + ? isSupportedScriptType(network, 'p2trMusig2') + : isSupportedScriptType(network, scriptType) + ) + .map((scriptType) => ({ scriptType, value: BigInt(2000) })); + + const outputs: Output[] = outputScriptTypes + .filter((scriptType) => isSupportedScriptType(network, scriptType)) + .map((scriptType) => ({ scriptType, value: BigInt(900) })); + + // Test other wallet output (with derivation info) + outputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: otherWalletKeys }); + // Tes non-wallet output + outputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: null }); + // Test OP_RETURN output + outputs.push({ opReturn: 'setec astronomy', value: BigInt(900) }); + + return new AcidTest(network, signStage, txFormat, rootWalletKeys, otherWalletKeys, inputs, outputs); + } + + get name(): string { + const networkName = getNetworkName(this.network); + return `${networkName} ${this.signStage} ${this.txFormat}`; + } + + createPsbt(): UtxoPsbt { + const psbt = constructPsbt(this.inputs, this.outputs, this.network, this.rootWalletKeys, this.signStage, { + deterministic: true, + addGlobalXPubs: true, + }); + if (this.txFormat === 'psbt-lite') { + return clonePsbtWithoutNonWitnessUtxo(psbt); + } + return psbt; + } + + static suite(): AcidTest[] { + return getNetworkList() + .filter((network) => isMainnet(network) && network !== networks.bitcoinsv) + .flatMap((network) => + signStages.flatMap((signStage) => + txFormats.flatMap((txFormat) => AcidTest.withDefaults(network, signStage, txFormat)) + ) + ); + } } /** diff --git a/modules/utxo-lib/src/testutil/transaction.ts b/modules/utxo-lib/src/testutil/transaction.ts index 97d883cd00..2c47887ef8 100644 --- a/modules/utxo-lib/src/testutil/transaction.ts +++ b/modules/utxo-lib/src/testutil/transaction.ts @@ -154,8 +154,12 @@ export function constructTxnBuilder( const outputInputAmount = outputs.reduce((sum, output) => sum + BigInt(output.value), BigInt(0)); assert(totalInputAmount >= outputInputAmount, 'total output can not exceed total input'); assert( - !outputs.some((o) => (o.scriptType && o.address) || (!o.scriptType && !o.address)), - 'only either output script type or address should be provided' + outputs.every((o) => o.scriptType || o.address), + 'must provide either scriptType or address for each output' + ); + assert( + outputs.every((o) => !(o.scriptType && o.address)), + 'cannot provide both scriptType and address for the same output' ); const txb = createTransactionBuilderForNetwork(network); diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts new file mode 100644 index 0000000000..5a1398d50f --- /dev/null +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyLegacy.ts @@ -0,0 +1,52 @@ +import * as assert from 'assert'; + +import { getStrictSignatureCount, getStrictSignatureCounts } from '../../../src/bitgo'; +import { constructTxnBuilder, TxnInput } from '../../../src/testutil'; +import { AcidTest, SignStage } from '../../../src/testutil/psbt'; +import { getNetworkName } from '../../../src'; + +function signCount(signStage: SignStage) { + return signStage === 'unsigned' ? 0 : signStage === 'halfsigned' ? 1 : 2; +} + +function runTx(acidTest: AcidTest) { + const coin = getNetworkName(acidTest.network); + const signatureCount = signCount(acidTest.signStage); + describe(`tx build, sign and verify for ${coin} ${acidTest.signStage}`, function () { + const inputs = acidTest.inputs.filter( + (input): input is TxnInput => + input.scriptType !== 'taprootKeyPathSpend' && input.scriptType !== 'p2trMusig2' + ); + const outputs = acidTest.outputs.filter( + (output) => + ('scriptType' in output && output.scriptType !== undefined) || + ('address' in output && output.address !== undefined) + ); + it(`tx signature counts ${coin} ${acidTest.signStage}`, function () { + const txb = constructTxnBuilder(inputs, outputs, acidTest.network, acidTest.rootWalletKeys, acidTest.signStage); + const tx = acidTest.signStage === 'fullsigned' ? txb.build() : txb.buildIncomplete(); + + const counts = getStrictSignatureCounts(tx); + const countsFromIns = getStrictSignatureCounts(tx.ins); + + assert.strictEqual(counts.length, tx.ins.length); + assert.strictEqual(countsFromIns.length, tx.ins.length); + tx.ins.forEach((input, inputIndex) => { + const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; + assert.strictEqual( + getStrictSignatureCount(input), + expectedCount, + `input ${inputIndex} has ${getStrictSignatureCount(input)} signatures, expected ${expectedCount}` + ); + assert.strictEqual(counts[inputIndex], expectedCount); + assert.strictEqual(countsFromIns[inputIndex], expectedCount); + }); + }); + }); +} + +AcidTest.suite() + .filter((acidTest) => acidTest.txFormat === 'psbt') + .forEach((acidTest) => { + runTx(acidTest); + }); diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts new file mode 100644 index 0000000000..36c0350c84 --- /dev/null +++ b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbt.ts @@ -0,0 +1,175 @@ +import * as assert from 'assert'; + +import { + createPsbtFromBuffer, + getPsbtInputSignatureCount, + getSignatureValidationArrayPsbt, + getStrictSignatureCount, + getStrictSignatureCounts, + PsbtInput, + PsbtOutput, + Triple, + UtxoPsbt, + UtxoTransaction, +} from '../../../src/bitgo'; +import { Input as TestUtilInput } from '../../../src/testutil'; +import { AcidTest, InputScriptType, SignStage } from '../../../src/testutil/psbt'; +import { getNetworkName } from '../../../src'; +import { + parsePsbtMusig2Nonces, + parsePsbtMusig2PartialSigs, + parsePsbtMusig2Participants, +} from '../../../src/bitgo/Musig2'; +import { getFixture } from '../../fixture.util'; + +function getSigValidArray(scriptType: InputScriptType, signStage: SignStage): Triple { + if (scriptType === 'p2shP2pk' || signStage === 'unsigned') { + return [false, false, false]; + } + if (signStage === 'halfsigned') { + return [true, false, false]; + } + return scriptType === 'p2trMusig2' ? [true, true, false] : [true, false, true]; +} + +function signCount(signStage: SignStage) { + return signStage === 'unsigned' ? 0 : signStage === 'halfsigned' ? 1 : 2; +} + +// normalize buffers to hex +function toFixture(obj: unknown) { + if (obj === null || obj === undefined) { + return obj; + } + if (typeof obj === 'bigint') { + return obj.toString(); + } + if (Buffer.isBuffer(obj)) { + return obj.toString('hex'); + } + if (Array.isArray(obj)) { + return obj.map(toFixture); + } + if (typeof obj === 'object') { + return Object.fromEntries( + Object.entries(obj).flatMap(([key, value]) => (value === undefined ? [] : [[key, toFixture(value)]])) + ); + } + return obj; +} + +function getFixturePsbtInputs(psbt: UtxoPsbt, inputs: TestUtilInput[]) { + if (inputs.length !== psbt.data.inputs.length) { + throw new Error('inputs length mismatch'); + } + return psbt.data.inputs.map((input: PsbtInput, index: number) => + toFixture({ + type: inputs[index].scriptType, + ...input, + musig2Participants: parsePsbtMusig2Participants(input), + musig2Nonces: parsePsbtMusig2Nonces(input), + musig2PartialSigs: parsePsbtMusig2PartialSigs(input), + }) + ); +} + +function getFixturePsbtOutputs(psbt: UtxoPsbt) { + return psbt.data.outputs.map((output: PsbtOutput) => toFixture(output)); +} + +function runPsbt(acidTest: AcidTest) { + const coin = getNetworkName(acidTest.network); + const signatureCount = signCount(acidTest.signStage); + + describe(`psbt suite for ${acidTest.name}`, function () { + let psbt: UtxoPsbt; + + before(function () { + psbt = acidTest.createPsbt(); + }); + + it('round-trip test', function () { + assert.deepStrictEqual(psbt.toBuffer(), createPsbtFromBuffer(psbt.toBuffer(), acidTest.network).toBuffer()); + }); + + it('matches fixture', async function () { + let finalizedPsbt: UtxoPsbt | undefined; + let extractedTransaction: Buffer | undefined; + if (acidTest.signStage === 'fullsigned') { + finalizedPsbt = psbt.clone().finalizeAllInputs(); + extractedTransaction = finalizedPsbt.extractTransaction().toBuffer(); + } + const fixture = { + walletKeys: acidTest.rootWalletKeys.triple.map((xpub) => xpub.toBase58()), + psbtBase64: psbt.toBase64(), + psbtBase64Finalized: finalizedPsbt ? finalizedPsbt.toBase64() : null, + inputs: psbt.txInputs.map((input) => toFixture(input)), + psbtInputs: getFixturePsbtInputs(psbt, acidTest.inputs), + psbtInputsFinalized: finalizedPsbt ? getFixturePsbtInputs(finalizedPsbt, acidTest.inputs) : null, + outputs: psbt.txOutputs.map((output) => toFixture(output)), + psbtOutputs: getFixturePsbtOutputs(psbt), + extractedTransaction: extractedTransaction ? toFixture(extractedTransaction) : null, + }; + const filename = [acidTest.txFormat, coin, acidTest.signStage, 'json'].join('.'); + assert.deepStrictEqual(fixture, await getFixture(`${__dirname}/../fixtures/psbt/${filename}`, fixture)); + }); + + it(`getSignatureValidationArray`, function () { + psbt.data.inputs.forEach((input, inputIndex) => { + const isP2shP2pk = acidTest.inputs[inputIndex].scriptType === 'p2shP2pk'; + const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); + psbt.getSignatureValidationArray(inputIndex, { rootNodes: acidTest.rootWalletKeys.triple }).forEach((sv, i) => { + if (isP2shP2pk && acidTest.signStage !== 'unsigned' && i === 0) { + assert.strictEqual(sv, true); + } else { + assert.strictEqual(sv, expectedSigValid[i]); + } + }); + }); + }); + + it(`getSignatureValidationArrayPsbt`, function () { + const sigValidations = getSignatureValidationArrayPsbt(psbt, acidTest.rootWalletKeys); + psbt.data.inputs.forEach((input, inputIndex) => { + const expectedSigValid = getSigValidArray(acidTest.inputs[inputIndex].scriptType, acidTest.signStage); + const sigValid = sigValidations.find((sv) => sv[0] === inputIndex); + assert.ok(sigValid); + sigValid[1].forEach((sv, i) => assert.strictEqual(sv, expectedSigValid[i])); + }); + }); + + it(`psbt signature counts`, function () { + const counts = getStrictSignatureCounts(psbt); + const countsFromInputs = getStrictSignatureCounts(psbt.data.inputs); + + assert.strictEqual(counts.length, psbt.data.inputs.length); + assert.strictEqual(countsFromInputs.length, psbt.data.inputs.length); + psbt.data.inputs.forEach((input, inputIndex) => { + const expectedCount = + acidTest.inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; + assert.strictEqual(getPsbtInputSignatureCount(input), expectedCount); + assert.strictEqual(getStrictSignatureCount(input), expectedCount); + assert.strictEqual(counts[inputIndex], expectedCount); + assert.strictEqual(countsFromInputs[inputIndex], expectedCount); + }); + + if (acidTest.signStage === 'fullsigned') { + const tx = psbt.finalizeAllInputs().extractTransaction() as UtxoTransaction; + const counts = getStrictSignatureCounts(tx); + const countsFromIns = getStrictSignatureCounts(tx.ins); + + tx.ins.forEach((input, inputIndex) => { + const expectedCount = + acidTest.inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; + assert.strictEqual(getStrictSignatureCount(input), expectedCount); + assert.strictEqual(counts[inputIndex], expectedCount); + assert.strictEqual(countsFromIns[inputIndex], expectedCount); + }); + } + }); + }); +} + +AcidTest.suite().forEach((acidTest) => { + runPsbt(acidTest); +}); diff --git a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts b/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts deleted file mode 100644 index f5a86cf3aa..0000000000 --- a/modules/utxo-lib/test/bitgo/psbt/SignVerifyPsbtAndTx.ts +++ /dev/null @@ -1,283 +0,0 @@ -import * as assert from 'assert'; - -import { - addXpubsToPsbt, - clonePsbtWithoutNonWitnessUtxo, - getPsbtInputSignatureCount, - getSignatureValidationArrayPsbt, - getStrictSignatureCount, - getStrictSignatureCounts, - PsbtInput, - PsbtOutput, - RootWalletKeys, - Triple, - UtxoPsbt, - UtxoTransaction, -} from '../../../src/bitgo'; -import { BIP32Interface } from '@bitgo/secp256k1'; -import { - constructPsbt, - constructTxnBuilder, - getDefaultWalletKeys, - Input as TestUtilInput, - InputScriptType, - inputScriptTypes, - Output as TestUtilOutput, - outputScriptTypes, - TxnInput, - txnInputScriptTypes, - TxnOutput, - txnOutputScriptTypes, - getWalletKeysForSeed, -} from '../../../src/testutil'; -import { Output as TestutilPsbtOutput } from '../../../src/testutil/psbt'; -import { getNetworkList, getNetworkName, isMainnet, Network, networks } from '../../../src'; -import { isSupportedScriptType } from '../../../src/bitgo/outputScripts'; -import { - parsePsbtMusig2Nonces, - parsePsbtMusig2PartialSigs, - parsePsbtMusig2Participants, -} from '../../../src/bitgo/Musig2'; -import { SignatureTargetType } from './Psbt'; -import { getFixture } from '../../fixture.util'; - -const rootWalletKeys = getDefaultWalletKeys(); -const signs = ['unsigned', 'halfsigned', 'fullsigned'] as const; - -const rootWalletKeysXpubs = new RootWalletKeys( - rootWalletKeys.triple.map((bip32) => bip32.neutered()) as Triple, - rootWalletKeys.derivationPrefixes -); - -const psbtInputs = inputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(2000) })); -const psbtOutputs: TestutilPsbtOutput[] = outputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(900) })); - -const otherWalletKeys = getWalletKeysForSeed('too many secrets'); -// Test other wallet output -psbtOutputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: otherWalletKeys }); -// Test non-wallet output -psbtOutputs.push({ scriptType: 'p2sh', value: BigInt(900), walletKeys: null }); -// Test OP_RETURN output -psbtOutputs.push({ opReturn: 'setec astronomy', value: BigInt(900) }); - -const txInputs = txnInputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(1000) })); -const txOutputs = txnOutputScriptTypes.map((scriptType) => ({ scriptType, value: BigInt(900) })); - -function getSigValidArray(scriptType: InputScriptType, sign: SignatureTargetType): Triple { - if (scriptType === 'p2shP2pk' || sign === 'unsigned') { - return [false, false, false]; - } - if (sign === 'halfsigned') { - return [true, false, false]; - } - return scriptType === 'p2trMusig2' ? [true, true, false] : [true, false, true]; -} - -function signCount(sign: SignatureTargetType) { - return sign === 'unsigned' ? 0 : sign === 'halfsigned' ? 1 : 2; -} - -// normalize buffers to hex -function toFixture(obj: unknown) { - if (obj === null || obj === undefined) { - return obj; - } - if (typeof obj === 'bigint') { - return obj.toString(); - } - if (Buffer.isBuffer(obj)) { - return obj.toString('hex'); - } - if (Array.isArray(obj)) { - return obj.map(toFixture); - } - if (typeof obj === 'object') { - return Object.fromEntries( - Object.entries(obj).flatMap(([key, value]) => (value === undefined ? [] : [[key, toFixture(value)]])) - ); - } - return obj; -} - -function getFixturePsbtInputs(psbt: UtxoPsbt, inputs: TestUtilInput[]) { - if (inputs.length !== psbt.data.inputs.length) { - throw new Error('inputs length mismatch'); - } - return psbt.data.inputs.map((input: PsbtInput, index: number) => - toFixture({ - type: inputs[index].scriptType, - ...input, - musig2Participants: parsePsbtMusig2Participants(input), - musig2Nonces: parsePsbtMusig2Nonces(input), - musig2PartialSigs: parsePsbtMusig2PartialSigs(input), - }) - ); -} - -function getFixturePsbtOutputs(psbt: UtxoPsbt) { - return psbt.data.outputs.map((output: PsbtOutput) => toFixture(output)); -} - -function runPsbt( - network: Network, - sign: SignatureTargetType, - inputs: TestUtilInput[], - outputs: TestUtilOutput[], - { - txFormat, - }: { - txFormat?: 'psbt' | 'psbt-lite'; - } -) { - const coin = getNetworkName(network); - const signatureCount = signCount(sign); - const inputTypes = inputs.map((input) => input.scriptType); - - describe(`psbt build, sign and verify for ${coin} ${inputTypes.join('-')} ${sign}`, function () { - let psbt: UtxoPsbt; - - it(`getSignatureValidationArray with globalXpub ${coin} ${sign}`, function () { - psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign, { deterministic: true }); - addXpubsToPsbt(psbt, rootWalletKeysXpubs); - psbt.data.inputs.forEach((input, inputIndex) => { - const isP2shP2pk = inputs[inputIndex].scriptType === 'p2shP2pk'; - const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); - psbt.getSignatureValidationArray(inputIndex, { rootNodes: rootWalletKeys.triple }).forEach((sv, i) => { - if (isP2shP2pk && sign !== 'unsigned' && i === 0) { - assert.strictEqual(sv, true); - } else { - assert.strictEqual(sv, expectedSigValid[i]); - } - }); - }); - - if (txFormat === 'psbt-lite') { - psbt = clonePsbtWithoutNonWitnessUtxo(psbt); - } - }); - - it('matches fixture', async function () { - let finalizedPsbt: UtxoPsbt | undefined; - let extractedTransaction: Buffer | undefined; - if (sign === 'fullsigned') { - finalizedPsbt = psbt.clone().finalizeAllInputs(); - extractedTransaction = finalizedPsbt.extractTransaction().toBuffer(); - } - const fixture = { - walletKeys: rootWalletKeys.triple.map((xpub) => xpub.toBase58()), - psbtBase64: psbt.toBase64(), - psbtBase64Finalized: finalizedPsbt ? finalizedPsbt.toBase64() : null, - inputs: psbt.txInputs.map((input) => toFixture(input)), - psbtInputs: getFixturePsbtInputs(psbt, inputs), - psbtInputsFinalized: finalizedPsbt ? getFixturePsbtInputs(finalizedPsbt, inputs) : null, - outputs: psbt.txOutputs.map((output) => toFixture(output)), - psbtOutputs: getFixturePsbtOutputs(psbt), - extractedTransaction: extractedTransaction ? toFixture(extractedTransaction) : null, - }; - const filename = [txFormat, coin, sign, 'json'].join('.'); - assert.deepStrictEqual(fixture, await getFixture(`${__dirname}/../fixtures/psbt/${filename}`, fixture)); - }); - - it(`getSignatureValidationArray with rootNodes ${coin} ${sign}`, function () { - const psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign); - addXpubsToPsbt(psbt, rootWalletKeysXpubs); - psbt.data.inputs.forEach((input, inputIndex) => { - const isP2shP2pk = inputs[inputIndex].scriptType === 'p2shP2pk'; - const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); - psbt.getSignatureValidationArray(inputIndex, { rootNodes: rootWalletKeysXpubs.triple }).forEach((sv, i) => { - if (isP2shP2pk && sign !== 'unsigned' && i === 0) { - assert.strictEqual(sv, true); - } else { - assert.strictEqual(sv, expectedSigValid[i]); - } - }); - }); - }); - - it(`getSignatureValidationArrayPsbt ${coin} ${sign}`, function () { - const psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign); - const sigValidations = getSignatureValidationArrayPsbt(psbt, rootWalletKeysXpubs); - psbt.data.inputs.forEach((input, inputIndex) => { - const expectedSigValid = getSigValidArray(inputs[inputIndex].scriptType, sign); - const sigValid = sigValidations.find((sv) => sv[0] === inputIndex); - assert.ok(sigValid); - sigValid[1].forEach((sv, i) => assert.strictEqual(sv, expectedSigValid[i])); - }); - }); - - it(`psbt signature counts ${coin} ${sign}`, function () { - const psbt = constructPsbt(inputs, outputs, network, rootWalletKeys, sign); - const counts = getStrictSignatureCounts(psbt); - const countsFromInputs = getStrictSignatureCounts(psbt.data.inputs); - - assert.strictEqual(counts.length, psbt.data.inputs.length); - assert.strictEqual(countsFromInputs.length, psbt.data.inputs.length); - psbt.data.inputs.forEach((input, inputIndex) => { - const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; - assert.strictEqual(getPsbtInputSignatureCount(input), expectedCount); - assert.strictEqual(getStrictSignatureCount(input), expectedCount); - assert.strictEqual(counts[inputIndex], expectedCount); - assert.strictEqual(countsFromInputs[inputIndex], expectedCount); - }); - - if (sign === 'fullsigned') { - const tx = psbt.finalizeAllInputs().extractTransaction() as UtxoTransaction; - const counts = getStrictSignatureCounts(tx); - const countsFromIns = getStrictSignatureCounts(tx.ins); - - tx.ins.forEach((input, inputIndex) => { - const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' ? 1 : signatureCount; - assert.strictEqual(getStrictSignatureCount(input), expectedCount); - assert.strictEqual(counts[inputIndex], expectedCount); - assert.strictEqual(countsFromIns[inputIndex], expectedCount); - }); - } - }); - }); -} - -function runTx( - network: Network, - sign: SignatureTargetType, - inputs: TxnInput[], - outputs: TxnOutput[] -) { - const coin = getNetworkName(network); - const signatureCount = signCount(sign); - describe(`tx build, sign and verify for ${coin} ${sign}`, function () { - it(`tx signature counts ${coin} ${sign}`, function () { - const txb = constructTxnBuilder(inputs, outputs, network, rootWalletKeys, sign); - const tx = sign === 'fullsigned' ? txb.build() : txb.buildIncomplete(); - - const counts = getStrictSignatureCounts(tx); - const countsFromIns = getStrictSignatureCounts(tx.ins); - - assert.strictEqual(counts.length, tx.ins.length); - assert.strictEqual(countsFromIns.length, tx.ins.length); - tx.ins.forEach((input, inputIndex) => { - const expectedCount = inputs[inputIndex].scriptType === 'p2shP2pk' && signatureCount > 0 ? 1 : signatureCount; - assert.strictEqual(getStrictSignatureCount(input), expectedCount); - assert.strictEqual(counts[inputIndex], expectedCount); - assert.strictEqual(countsFromIns[inputIndex], expectedCount); - }); - }); - }); -} - -signs.forEach((sign) => { - getNetworkList() - .filter((v) => isMainnet(v) && v !== networks.bitcoinsv) - .forEach((network) => { - const supportedPsbtInputs = psbtInputs.filter((input) => - isSupportedScriptType(network, input.scriptType === 'taprootKeyPathSpend' ? 'p2trMusig2' : input.scriptType) - ); - const supportedPsbtOutputs = psbtOutputs.filter((output) => - 'scriptType' in output ? isSupportedScriptType(network, output.scriptType) : true - ); - runPsbt(network, sign, supportedPsbtInputs, supportedPsbtOutputs, { txFormat: 'psbt' }); - runPsbt(network, sign, supportedPsbtInputs, supportedPsbtOutputs, { txFormat: 'psbt-lite' }); - const supportedTxInputs = txInputs.filter((input) => isSupportedScriptType(network, input.scriptType)); - const supportedTxOutputs = txOutputs.filter((output) => isSupportedScriptType(network, output.scriptType)); - runTx(network, sign, supportedTxInputs, supportedTxOutputs); - }); -});