Skip to content

Commit 6e44eca

Browse files
Merge pull request #7676 from BitGo/BTC-2806.refactor-signtx
feat(abstract-utxo): refactor transaction signing functionality
2 parents cec5167 + 1121868 commit 6e44eca

File tree

13 files changed

+373
-256
lines changed

13 files changed

+373
-256
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptor
7777
import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } from './names';
7878
import { assertFixedScriptWalletAddress } from './address/fixedScript';
7979
import { ParsedTransaction } from './transaction/types';
80+
import { decodePsbtWith, stringToBufferTryFormats } from './transaction/decode';
8081
import { toBip32Triple, UtxoKeychain } from './keychains';
8182
import { verifyKeySignature, verifyUserPublicKey } from './verifyKey';
8283
import { getPolicyForEnv } from './descriptor/validatePolicy';
@@ -527,22 +528,12 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
527528

528529
decodeTransaction<TNumber extends number | bigint>(input: Buffer | string): DecodedTransaction<TNumber> {
529530
if (typeof input === 'string') {
530-
for (const format of ['hex', 'base64'] as const) {
531-
const buffer = Buffer.from(input, format);
532-
const bufferToString = buffer.toString(format);
533-
if (
534-
(format === 'base64' && bufferToString === input) ||
535-
(format === 'hex' && bufferToString === input.toLowerCase())
536-
) {
537-
return this.decodeTransaction(buffer);
538-
}
539-
}
540-
541-
throw new Error('input must be a valid hex or base64 string');
531+
const buffer = stringToBufferTryFormats(input, ['hex', 'base64']);
532+
return this.decodeTransaction(buffer);
542533
}
543534

544535
if (utxolib.bitgo.isPsbt(input)) {
545-
return utxolib.bitgo.createPsbtFromBuffer(input, this.network);
536+
return decodePsbtWith(input, this.network, 'utxolib');
546537
} else {
547538
return utxolib.bitgo.createTransactionFromBuffer(input, this.network, {
548539
amountType: this.amountType,

modules/abstract-utxo/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export * from './address';
33
export * from './config';
44
export * from './recovery';
55
export * from './transaction/fixedScript/replayProtection';
6-
export * from './transaction/fixedScript/sign';
6+
export * from './transaction/fixedScript/signLegacyTransaction';
77

88
export { UtxoWallet } from './wallet';
99
export * as descriptor from './descriptor';

modules/abstract-utxo/src/recovery/backupKeyRecovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { getMainnet, networks } from '@bitgo/utxo-lib';
1717

1818
import { AbstractUtxoCoin } from '../abstractUtxoCoin';
19-
import { signAndVerifyPsbt } from '../transaction/fixedScript/sign';
19+
import { signAndVerifyPsbt } from '../transaction/fixedScript/signPsbt';
2020
import { generateAddressWithChainAndIndex } from '../address';
2121

2222
import { forCoin, RecoveryProvider } from './RecoveryProvider';

modules/abstract-utxo/src/recovery/crossChainRecovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BitGoBase, IWallet, Keychain, Triple, Wallet } from '@bitgo/sdk-core';
55
import { decrypt } from '@bitgo/sdk-api';
66

77
import { AbstractUtxoCoin, TransactionInfo } from '../abstractUtxoCoin';
8-
import { signAndVerifyWalletTransaction } from '../transaction/fixedScript/sign';
8+
import { signAndVerifyWalletTransaction } from '../transaction/fixedScript/signLegacyTransaction';
99

1010
const { unspentSum, scriptTypeForChain, outputScripts } = utxolib.bitgo;
1111
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo';
3+
4+
import { SdkBackend } from './types';
5+
6+
type BufferEncoding = 'hex' | 'base64';
7+
8+
export function stringToBufferTryFormats(input: string, formats: BufferEncoding[] = ['hex', 'base64']): Buffer {
9+
for (const format of formats) {
10+
const buffer = Buffer.from(input, format);
11+
const bufferToString = buffer.toString(format);
12+
if (
13+
(format === 'base64' && bufferToString === input) ||
14+
(format === 'hex' && bufferToString === input.toLowerCase())
15+
) {
16+
return buffer;
17+
}
18+
}
19+
20+
throw new Error('input must be a valid hex or base64 string');
21+
}
22+
23+
function toNetworkName(network: utxolib.Network): utxolibCompat.UtxolibName {
24+
const networkName = utxolib.getNetworkName(network);
25+
if (!networkName) {
26+
throw new Error(`Invalid network: ${network}`);
27+
}
28+
return networkName;
29+
}
30+
31+
export function decodePsbtWith(
32+
psbt: string | Buffer,
33+
network: utxolib.Network,
34+
backend: 'utxolib'
35+
): utxolib.bitgo.UtxoPsbt;
36+
export function decodePsbtWith(
37+
psbt: string | Buffer,
38+
network: utxolib.Network,
39+
backend: 'wasm-utxo'
40+
): fixedScriptWallet.BitGoPsbt;
41+
export function decodePsbtWith(
42+
psbt: string | Buffer,
43+
network: utxolib.Network,
44+
backend: SdkBackend
45+
): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt;
46+
export function decodePsbtWith(
47+
psbt: string | Buffer,
48+
network: utxolib.Network,
49+
backend: SdkBackend
50+
): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt {
51+
if (typeof psbt === 'string') {
52+
psbt = Buffer.from(psbt, 'hex');
53+
}
54+
if (backend === 'utxolib') {
55+
return utxolib.bitgo.createPsbtFromBuffer(psbt, network);
56+
} else {
57+
return fixedScriptWallet.BitGoPsbt.fromBytes(psbt, toNetworkName(network));
58+
}
59+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
3+
import type { PsbtParsedScriptType } from './signPsbt';
4+
5+
type Unspent<TNumber extends number | bigint = number> = utxolib.bitgo.Unspent<TNumber>;
6+
7+
export class InputSigningError<TNumber extends number | bigint = number> extends Error {
8+
static expectedWalletUnspent<TNumber extends number | bigint>(
9+
inputIndex: number,
10+
inputType: PsbtParsedScriptType | null, // null for legacy transaction format
11+
unspent: Unspent<TNumber> | { id: string }
12+
): InputSigningError<TNumber> {
13+
return new InputSigningError(
14+
inputIndex,
15+
inputType,
16+
unspent,
17+
`not a wallet unspent, not a replay protection unspent`
18+
);
19+
}
20+
21+
constructor(
22+
public inputIndex: number,
23+
public inputType: PsbtParsedScriptType | null, // null for legacy transaction format
24+
public unspent: Unspent<TNumber> | { id: string },
25+
public reason: Error | string
26+
) {
27+
super(`signing error at input ${inputIndex}: type=${inputType} unspentId=${unspent.id}: ${reason}`);
28+
}
29+
}
30+
31+
export class TransactionSigningError<TNumber extends number | bigint = number> extends Error {
32+
constructor(signErrors: InputSigningError<TNumber>[], verifyError: InputSigningError<TNumber>[]) {
33+
super(
34+
`sign errors at inputs: [${signErrors.join(',')}], ` +
35+
`verify errors at inputs: [${verifyError.join(',')}], see log for details`
36+
);
37+
}
38+
}

modules/abstract-utxo/src/transaction/fixedScript/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export { explainPsbtWasm } from './explainPsbtWasm';
33
export { parseTransaction } from './parseTransaction';
44
export { CustomChangeOptions } from './parseOutput';
55
export { verifyTransaction } from './verifyTransaction';
6-
export { signTransaction, Musig2Participant } from './signTransaction';
7-
export * from './sign';
6+
export { signTransaction } from './signTransaction';
7+
export { Musig2Participant } from './signPsbt';
8+
export * from './signLegacyTransaction';
9+
export * from './SigningError';
810
export * from './replayProtection';

modules/abstract-utxo/src/transaction/fixedScript/sign.ts renamed to modules/abstract-utxo/src/transaction/fixedScript/signLegacyTransaction.ts

Lines changed: 46 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import assert from 'assert';
2+
13
import * as utxolib from '@bitgo/utxo-lib';
4+
import { BIP32Interface, bip32 } from '@bitgo/secp256k1';
5+
import { bitgo } from '@bitgo/utxo-lib';
6+
import { isTriple, Triple } from '@bitgo/sdk-core';
27
import debugLib from 'debug';
38

49
import { getReplayProtectionAddresses } from './replayProtection';
10+
import { InputSigningError, TransactionSigningError } from './SigningError';
511

612
const debug = debugLib('bitgo:v2:utxo');
713

@@ -11,132 +17,6 @@ type Unspent<TNumber extends number | bigint = number> = utxolib.bitgo.Unspent<T
1117

1218
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
1319

14-
type PsbtParsedScriptType =
15-
| 'p2sh'
16-
| 'p2wsh'
17-
| 'p2shP2wsh'
18-
| 'p2shP2pk'
19-
| 'taprootKeyPathSpend'
20-
| 'taprootScriptPathSpend';
21-
22-
export class InputSigningError<TNumber extends number | bigint = number> extends Error {
23-
static expectedWalletUnspent<TNumber extends number | bigint>(
24-
inputIndex: number,
25-
inputType: PsbtParsedScriptType | null, // null for legacy transaction format
26-
unspent: Unspent<TNumber> | { id: string }
27-
): InputSigningError<TNumber> {
28-
return new InputSigningError(
29-
inputIndex,
30-
inputType,
31-
unspent,
32-
`not a wallet unspent, not a replay protection unspent`
33-
);
34-
}
35-
36-
constructor(
37-
public inputIndex: number,
38-
public inputType: PsbtParsedScriptType | null, // null for legacy transaction format
39-
public unspent: Unspent<TNumber> | { id: string },
40-
public reason: Error | string
41-
) {
42-
super(`signing error at input ${inputIndex}: type=${inputType} unspentId=${unspent.id}: ${reason}`);
43-
}
44-
}
45-
46-
export class TransactionSigningError<TNumber extends number | bigint = number> extends Error {
47-
constructor(signErrors: InputSigningError<TNumber>[], verifyError: InputSigningError<TNumber>[]) {
48-
super(
49-
`sign errors at inputs: [${signErrors.join(',')}], ` +
50-
`verify errors at inputs: [${verifyError.join(',')}], see log for details`
51-
);
52-
}
53-
}
54-
55-
/**
56-
* Sign all inputs of a psbt and verify signatures after signing.
57-
* Collects and logs signing errors and verification errors, throws error in the end if any of them
58-
* failed.
59-
*
60-
* If it is the last signature, finalize and extract the transaction from the psbt.
61-
*
62-
* This function mirrors signAndVerifyWalletTransaction, but is used for signing PSBTs instead of
63-
* using TransactionBuilder
64-
*
65-
* @param psbt
66-
* @param signerKeychain
67-
* @param isLastSignature
68-
*/
69-
export function signAndVerifyPsbt(
70-
psbt: utxolib.bitgo.UtxoPsbt,
71-
signerKeychain: utxolib.BIP32Interface,
72-
{
73-
isLastSignature,
74-
/** deprecated */
75-
allowNonSegwitSigningWithoutPrevTx,
76-
}: { isLastSignature: boolean; allowNonSegwitSigningWithoutPrevTx?: boolean }
77-
): utxolib.bitgo.UtxoPsbt | utxolib.bitgo.UtxoTransaction<bigint> {
78-
const txInputs = psbt.txInputs;
79-
const outputIds: string[] = [];
80-
const scriptTypes: PsbtParsedScriptType[] = [];
81-
82-
const signErrors: InputSigningError<bigint>[] = psbt.data.inputs
83-
.map((input, inputIndex: number) => {
84-
const outputId = utxolib.bitgo.formatOutputId(utxolib.bitgo.getOutputIdForInput(txInputs[inputIndex]));
85-
outputIds.push(outputId);
86-
87-
const { scriptType } = utxolib.bitgo.parsePsbtInput(input);
88-
scriptTypes.push(scriptType);
89-
90-
if (scriptType === 'p2shP2pk') {
91-
debug('Skipping signature for input %d of %d (RP input?)', inputIndex + 1, psbt.data.inputs.length);
92-
return;
93-
}
94-
95-
try {
96-
psbt.signInputHD(inputIndex, signerKeychain);
97-
debug('Successfully signed input %d of %d', inputIndex + 1, psbt.data.inputs.length);
98-
} catch (e) {
99-
return new InputSigningError<bigint>(inputIndex, scriptType, { id: outputId }, e);
100-
}
101-
})
102-
.filter((e): e is InputSigningError<bigint> => e !== undefined);
103-
104-
const verifyErrors: InputSigningError<bigint>[] = psbt.data.inputs
105-
.map((input, inputIndex) => {
106-
const scriptType = scriptTypes[inputIndex];
107-
if (scriptType === 'p2shP2pk') {
108-
debug(
109-
'Skipping input signature %d of %d (unspent from replay protection address which is platform signed only)',
110-
inputIndex + 1,
111-
psbt.data.inputs.length
112-
);
113-
return;
114-
}
115-
116-
const outputId = outputIds[inputIndex];
117-
try {
118-
if (!psbt.validateSignaturesOfInputHD(inputIndex, signerKeychain)) {
119-
return new InputSigningError(inputIndex, scriptType, { id: outputId }, new Error(`invalid signature`));
120-
}
121-
} catch (e) {
122-
debug('Invalid signature');
123-
return new InputSigningError<bigint>(inputIndex, scriptType, { id: outputId }, e);
124-
}
125-
})
126-
.filter((e): e is InputSigningError<bigint> => e !== undefined);
127-
128-
if (signErrors.length || verifyErrors.length) {
129-
throw new TransactionSigningError(signErrors, verifyErrors);
130-
}
131-
132-
if (isLastSignature) {
133-
psbt.finalizeAllInputs();
134-
return psbt.extractTransaction();
135-
}
136-
137-
return psbt;
138-
}
139-
14020
/**
14121
* Sign all inputs of a wallet transaction and verify signatures after signing.
14222
* Collects and logs signing errors and verification errors, throws error in the end if any of them
@@ -232,3 +112,43 @@ export function signAndVerifyWalletTransaction<TNumber extends number | bigint>(
232112

233113
return signedTransaction;
234114
}
115+
116+
export function signLegacyTransaction<TNumber extends number | bigint>(
117+
tx: utxolib.bitgo.UtxoTransaction<TNumber>,
118+
signerKeychain: BIP32Interface | undefined,
119+
params: {
120+
isLastSignature: boolean;
121+
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
122+
txInfo: { unspents?: utxolib.bitgo.Unspent<TNumber>[] } | undefined;
123+
pubs: string[] | undefined;
124+
cosignerPub: string | undefined;
125+
}
126+
): utxolib.bitgo.UtxoTransaction<TNumber> {
127+
switch (params.signingStep) {
128+
case 'signerNonce':
129+
case 'cosignerNonce':
130+
/**
131+
* In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s).
132+
* Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence.
133+
*/
134+
return tx;
135+
}
136+
137+
if (tx.ins.length !== params.txInfo?.unspents?.length) {
138+
throw new Error('length of unspents array should equal to the number of transaction inputs');
139+
}
140+
141+
if (!params.pubs || !isTriple(params.pubs)) {
142+
throw new Error(`must provide xpub array`);
143+
}
144+
145+
const keychains = params.pubs.map((pub) => bip32.fromBase58(pub)) as Triple<BIP32Interface>;
146+
const cosignerPub = params.cosignerPub ?? params.pubs[2];
147+
const cosignerKeychain = bip32.fromBase58(cosignerPub);
148+
149+
assert(signerKeychain);
150+
const walletSigner = new bitgo.WalletUnspentSigner<RootWalletKeys>(keychains, signerKeychain, cosignerKeychain);
151+
return signAndVerifyWalletTransaction(tx, params.txInfo.unspents, walletSigner, {
152+
isLastSignature: params.isLastSignature,
153+
}) as utxolib.bitgo.UtxoTransaction<TNumber>;
154+
}

0 commit comments

Comments
 (0)