Skip to content

Commit 29d3d55

Browse files
Merge pull request #5270 from BitGo/BTC-1450.factor-signtx
refactor(abstract-utxo): move signTransaction to its own file
2 parents 9e4d037 + ab39d31 commit 29d3d55

File tree

5 files changed

+196
-145
lines changed

5 files changed

+196
-145
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 5 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import assert from 'assert';
22
import { randomBytes } from 'crypto';
33
import _ from 'lodash';
44
import * as utxolib from '@bitgo/utxo-lib';
5-
import { bip32, BIP32Interface, bitgo, getMainnet, isMainnet, isTestnet } from '@bitgo/utxo-lib';
6-
import debugLib from 'debug';
5+
import { bip32, bitgo, getMainnet, isMainnet, isTestnet } from '@bitgo/utxo-lib';
76

87
import {
98
backupKeyRecovery,
@@ -59,7 +58,6 @@ import {
5958
Wallet,
6059
} from '@bitgo/sdk-core';
6160
import { isReplayProtectionUnspent } from './replayProtection';
62-
import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from './sign';
6361
import { supportedCrossChainRecoveries } from './config';
6462
import {
6563
assertValidTransactionRecipient,
@@ -76,10 +74,9 @@ import { CustomChangeOptions } from './transaction/fixedScript';
7674
import { toBip32Triple, UtxoKeychain, UtxoNamedKeychains } from './keychains';
7775
import { verifyKeySignature, verifyUserPublicKey } from './verifyKey';
7876
import { getPolicyForEnv } from './descriptor/validatePolicy';
77+
import { signTransaction } from './transaction/signTransaction';
7978
import { UtxoWallet } from './wallet';
8079

81-
const debug = debugLib('bitgo:v2:utxo');
82-
8380
import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
8481

8582
type UtxoCustomSigningFunction<TNumber extends number | bigint> = {
@@ -111,11 +108,11 @@ const { getExternalChainCode, isChainCode, scriptTypeForChain, outputScripts } =
111108

112109
type Unspent<TNumber extends number | bigint = number> = bitgo.Unspent<TNumber>;
113110

114-
type DecodedTransaction<TNumber extends number | bigint> =
111+
export type DecodedTransaction<TNumber extends number | bigint> =
115112
| utxolib.bitgo.UtxoTransaction<TNumber>
116113
| utxolib.bitgo.UtxoPsbt;
117114

118-
type RootWalletKeys = bitgo.RootWalletKeys;
115+
export type RootWalletKeys = bitgo.RootWalletKeys;
119116

120117
export type UtxoCoinSpecific = AddressCoinSpecific | DescriptorAddressCoinSpecific;
121118

@@ -377,16 +374,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
377374
this._network = network;
378375
}
379376

380-
/**
381-
* Key Value: Unsigned tx id => PSBT
382-
* It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated.
383-
* Reason: MuSig2 signer secure nonce is cached in the UtxoPsbt object. It will be required during the signing step.
384-
* For more info, check SignTransactionOptions.signingStep
385-
*
386-
* TODO BTC-276: This cache may need to be done with LRU like memory safe caching if memory issues comes up.
387-
*/
388-
private static readonly PSBT_CACHE = new Map<string, utxolib.bitgo.UtxoPsbt>();
389-
390377
get network() {
391378
return this._network;
392379
}
@@ -850,132 +837,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
850837
async signTransaction<TNumber extends number | bigint = number>(
851838
params: SignTransactionOptions<TNumber>
852839
): Promise<SignedTransaction | HalfSignedUtxoTransaction> {
853-
const txPrebuild = params.txPrebuild;
854-
855-
if (_.isUndefined(txPrebuild) || !_.isObject(txPrebuild)) {
856-
if (!_.isUndefined(txPrebuild) && !_.isObject(txPrebuild)) {
857-
throw new Error(`txPrebuild must be an object, got type ${typeof txPrebuild}`);
858-
}
859-
throw new Error('missing txPrebuild parameter');
860-
}
861-
862-
let tx = this.decodeTransactionFromPrebuild(params.txPrebuild);
863-
864-
const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx);
865-
866-
let isLastSignature = false;
867-
if (_.isBoolean(params.isLastSignature)) {
868-
// We can only be the first signature on a transaction with taproot key path spend inputs because
869-
// we require the secret nonce in the cache of the first signer, which is impossible to retrieve if
870-
// deserialized from a hex.
871-
if (params.isLastSignature && isTxWithKeyPathSpendInput) {
872-
throw new Error('Cannot be last signature on a transaction with key path spend inputs');
873-
}
874-
875-
// if build is called instead of buildIncomplete, no signature placeholders are left in the sig script
876-
isLastSignature = params.isLastSignature;
877-
}
878-
879-
const getSignerKeychain = (): utxolib.BIP32Interface => {
880-
const userPrv = params.prv;
881-
if (_.isUndefined(userPrv) || !_.isString(userPrv)) {
882-
if (!_.isUndefined(userPrv)) {
883-
throw new Error(`prv must be a string, got type ${typeof userPrv}`);
884-
}
885-
throw new Error('missing prv parameter to sign transaction');
886-
}
887-
const signerKeychain = bip32.fromBase58(userPrv, utxolib.networks.bitcoin);
888-
if (signerKeychain.isNeutered()) {
889-
throw new Error('expected user private key but received public key');
890-
}
891-
debug(`Here is the public key of the xprv you used to sign: ${signerKeychain.neutered().toBase58()}`);
892-
return signerKeychain;
893-
};
894-
895-
const setSignerMusigNonceWithOverride = (
896-
psbt: utxolib.bitgo.UtxoPsbt,
897-
signerKeychain: utxolib.BIP32Interface,
898-
nonSegwitOverride: boolean
899-
) => {
900-
utxolib.bitgo.withUnsafeNonSegwit(psbt, () => psbt.setAllInputsMusig2NonceHD(signerKeychain), nonSegwitOverride);
901-
};
902-
903-
let signerKeychain: utxolib.BIP32Interface | undefined;
904-
905-
if (tx instanceof bitgo.UtxoPsbt && isTxWithKeyPathSpendInput) {
906-
switch (params.signingStep) {
907-
case 'signerNonce':
908-
signerKeychain = getSignerKeychain();
909-
setSignerMusigNonceWithOverride(tx, signerKeychain, !!params.allowNonSegwitSigningWithoutPrevTx);
910-
AbstractUtxoCoin.PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx);
911-
return { txHex: tx.toHex() };
912-
case 'cosignerNonce':
913-
assert(txPrebuild.walletId, 'walletId is required for MuSig2 bitgo nonce');
914-
return { txHex: (await this.signPsbt(tx.toHex(), txPrebuild.walletId)).psbt };
915-
case 'signerSignature':
916-
const txId = tx.getUnsignedTx().getId();
917-
const psbt = AbstractUtxoCoin.PSBT_CACHE.get(txId);
918-
assert(
919-
psbt,
920-
`Psbt is missing from txCache (cache size ${AbstractUtxoCoin.PSBT_CACHE.size}).
921-
This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.`
922-
);
923-
AbstractUtxoCoin.PSBT_CACHE.delete(txId);
924-
tx = psbt.combine(tx);
925-
break;
926-
default:
927-
// this instance is not an external signer
928-
assert(txPrebuild.walletId, 'walletId is required for MuSig2 bitgo nonce');
929-
signerKeychain = getSignerKeychain();
930-
setSignerMusigNonceWithOverride(tx, signerKeychain, !!params.allowNonSegwitSigningWithoutPrevTx);
931-
const response = await this.signPsbt(tx.toHex(), txPrebuild.walletId);
932-
tx.combine(bitgo.createPsbtFromHex(response.psbt, this.network));
933-
break;
934-
}
935-
} else {
936-
switch (params.signingStep) {
937-
case 'signerNonce':
938-
case 'cosignerNonce':
939-
/**
940-
* In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s).
941-
* Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence.
942-
*/
943-
return { txHex: tx.toHex() };
944-
}
945-
}
946-
947-
if (signerKeychain === undefined) {
948-
signerKeychain = getSignerKeychain();
949-
}
950-
951-
let signedTransaction: bitgo.UtxoTransaction<bigint> | bitgo.UtxoPsbt;
952-
if (tx instanceof bitgo.UtxoPsbt) {
953-
signedTransaction = signAndVerifyPsbt(tx, signerKeychain, {
954-
isLastSignature,
955-
allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx,
956-
});
957-
} else {
958-
if (tx.ins.length !== txPrebuild.txInfo?.unspents?.length) {
959-
throw new Error('length of unspents array should equal to the number of transaction inputs');
960-
}
961-
962-
if (!params.pubs || !isTriple(params.pubs)) {
963-
throw new Error(`must provide xpub array`);
964-
}
965-
966-
const keychains = params.pubs.map((pub) => bip32.fromBase58(pub)) as Triple<BIP32Interface>;
967-
const cosignerPub = params.cosignerPub ?? params.pubs[2];
968-
const cosignerKeychain = bip32.fromBase58(cosignerPub);
969-
970-
const walletSigner = new bitgo.WalletUnspentSigner<RootWalletKeys>(keychains, signerKeychain, cosignerKeychain);
971-
signedTransaction = signAndVerifyWalletTransaction(tx, txPrebuild.txInfo.unspents, walletSigner, {
972-
isLastSignature,
973-
}) as bitgo.UtxoTransaction<bigint>;
974-
}
975-
976-
return {
977-
txHex: signedTransaction.toBuffer().toString('hex'),
978-
};
840+
return signTransaction<TNumber>(this, params);
979841
}
980842

981843
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as utxolib from '@bitgo/utxo-lib';
33
import { bip32, BIP32Interface } from '@bitgo/utxo-lib';
44

55
const { unspentSum, scriptTypeForChain, outputScripts } = utxolib.bitgo;
6-
export type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
6+
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
77
type Unspent<TNumber extends number | bigint = number> = utxolib.bitgo.Unspent<TNumber>;
88
type WalletUnspent<TNumber extends number | bigint = number> = utxolib.bitgo.WalletUnspent<TNumber>;
99
type WalletUnspentLegacy<TNumber extends number | bigint = number> = utxolib.bitgo.WalletUnspentLegacy<TNumber>;
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { explainPsbt, explainLegacyTx, ChangeAddressInfo } from './explainTransaction';
22
export { parseTransaction } from './parseTransaction';
3-
export { verifyTransaction } from './verifyTransaction';
43
export { CustomChangeOptions } from './parseOutput';
4+
export { verifyTransaction } from './verifyTransaction';
5+
export { signTransaction } from './signTransaction';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import assert from 'assert';
2+
import _ from 'lodash';
3+
import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib';
4+
import * as utxolib from '@bitgo/utxo-lib';
5+
import { isTriple, Triple } from '@bitgo/sdk-core';
6+
import buildDebug from 'debug';
7+
8+
import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from '../../sign';
9+
import { AbstractUtxoCoin, DecodedTransaction, RootWalletKeys } from '../../abstractUtxoCoin';
10+
11+
const debug = buildDebug('bitgo:abstract-utxo:signTransaction');
12+
13+
/**
14+
* Key Value: Unsigned tx id => PSBT
15+
* It is used to cache PSBTs with taproot key path (MuSig2) inputs during external express signer is activated.
16+
* Reason: MuSig2 signer secure nonce is cached in the UtxoPsbt object. It will be required during the signing step.
17+
* For more info, check SignTransactionOptions.signingStep
18+
*
19+
* TODO BTC-276: This cache may need to be done with LRU like memory safe caching if memory issues comes up.
20+
*/
21+
const PSBT_CACHE = new Map<string, utxolib.bitgo.UtxoPsbt>();
22+
23+
export async function signTransaction<TNumber extends number | bigint>(
24+
coin: AbstractUtxoCoin,
25+
tx: DecodedTransaction<TNumber>,
26+
params: {
27+
walletId: string | undefined;
28+
txInfo: { unspents?: utxolib.bitgo.Unspent<TNumber>[] } | undefined;
29+
isLastSignature: boolean;
30+
prv: string | undefined;
31+
signingStep: 'signerNonce' | 'cosignerNonce' | 'signerSignature' | undefined;
32+
allowNonSegwitSigningWithoutPrevTx: boolean;
33+
pubs: string[] | undefined;
34+
cosignerPub: string | undefined;
35+
}
36+
): Promise<{ txHex: string }> {
37+
const isTxWithKeyPathSpendInput = tx instanceof bitgo.UtxoPsbt && bitgo.isTransactionWithKeyPathSpendInput(tx);
38+
39+
let isLastSignature = false;
40+
if (_.isBoolean(params.isLastSignature)) {
41+
// We can only be the first signature on a transaction with taproot key path spend inputs because
42+
// we require the secret nonce in the cache of the first signer, which is impossible to retrieve if
43+
// deserialized from a hex.
44+
if (params.isLastSignature && isTxWithKeyPathSpendInput) {
45+
throw new Error('Cannot be last signature on a transaction with key path spend inputs');
46+
}
47+
48+
// if build is called instead of buildIncomplete, no signature placeholders are left in the sig script
49+
isLastSignature = params.isLastSignature;
50+
}
51+
52+
const getSignerKeychain = (): utxolib.BIP32Interface => {
53+
const userPrv = params.prv;
54+
if (_.isUndefined(userPrv) || !_.isString(userPrv)) {
55+
if (!_.isUndefined(userPrv)) {
56+
throw new Error(`prv must be a string, got type ${typeof userPrv}`);
57+
}
58+
throw new Error('missing prv parameter to sign transaction');
59+
}
60+
const signerKeychain = bip32.fromBase58(userPrv, utxolib.networks.bitcoin);
61+
if (signerKeychain.isNeutered()) {
62+
throw new Error('expected user private key but received public key');
63+
}
64+
debug(`Here is the public key of the xprv you used to sign: ${signerKeychain.neutered().toBase58()}`);
65+
return signerKeychain;
66+
};
67+
68+
const setSignerMusigNonceWithOverride = (
69+
psbt: utxolib.bitgo.UtxoPsbt,
70+
signerKeychain: utxolib.BIP32Interface,
71+
nonSegwitOverride: boolean
72+
) => {
73+
utxolib.bitgo.withUnsafeNonSegwit(psbt, () => psbt.setAllInputsMusig2NonceHD(signerKeychain), nonSegwitOverride);
74+
};
75+
76+
let signerKeychain: utxolib.BIP32Interface | undefined;
77+
78+
if (tx instanceof bitgo.UtxoPsbt && isTxWithKeyPathSpendInput) {
79+
switch (params.signingStep) {
80+
case 'signerNonce':
81+
signerKeychain = getSignerKeychain();
82+
setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx);
83+
PSBT_CACHE.set(tx.getUnsignedTx().getId(), tx);
84+
return { txHex: tx.toHex() };
85+
case 'cosignerNonce':
86+
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
87+
return { txHex: (await coin.signPsbt(tx.toHex(), params.walletId)).psbt };
88+
case 'signerSignature':
89+
const txId = tx.getUnsignedTx().getId();
90+
const psbt = PSBT_CACHE.get(txId);
91+
assert(
92+
psbt,
93+
`Psbt is missing from txCache (cache size ${PSBT_CACHE.size}).
94+
This may be due to the request being routed to a different BitGo-Express instance that for signing step 'signerNonce'.`
95+
);
96+
PSBT_CACHE.delete(txId);
97+
tx = psbt.combine(tx);
98+
break;
99+
default:
100+
// this instance is not an external signer
101+
assert(params.walletId, 'walletId is required for MuSig2 bitgo nonce');
102+
signerKeychain = getSignerKeychain();
103+
setSignerMusigNonceWithOverride(tx, signerKeychain, params.allowNonSegwitSigningWithoutPrevTx);
104+
const response = await coin.signPsbt(tx.toHex(), params.walletId);
105+
tx.combine(bitgo.createPsbtFromHex(response.psbt, coin.network));
106+
break;
107+
}
108+
} else {
109+
switch (params.signingStep) {
110+
case 'signerNonce':
111+
case 'cosignerNonce':
112+
/**
113+
* In certain cases, the caller of this method may not know whether the txHex contains a psbt with taproot key path spend input(s).
114+
* Instead of throwing error, no-op and return the txHex. So that the caller can call this method in the same sequence.
115+
*/
116+
return { txHex: tx.toHex() };
117+
}
118+
}
119+
120+
if (signerKeychain === undefined) {
121+
signerKeychain = getSignerKeychain();
122+
}
123+
124+
let signedTransaction: bitgo.UtxoTransaction<bigint> | bitgo.UtxoPsbt;
125+
if (tx instanceof bitgo.UtxoPsbt) {
126+
signedTransaction = signAndVerifyPsbt(tx, signerKeychain, {
127+
isLastSignature,
128+
allowNonSegwitSigningWithoutPrevTx: params.allowNonSegwitSigningWithoutPrevTx,
129+
});
130+
} else {
131+
if (tx.ins.length !== params.txInfo?.unspents?.length) {
132+
throw new Error('length of unspents array should equal to the number of transaction inputs');
133+
}
134+
135+
if (!params.pubs || !isTriple(params.pubs)) {
136+
throw new Error(`must provide xpub array`);
137+
}
138+
139+
const keychains = params.pubs.map((pub) => bip32.fromBase58(pub)) as Triple<BIP32Interface>;
140+
const cosignerPub = params.cosignerPub ?? params.pubs[2];
141+
const cosignerKeychain = bip32.fromBase58(cosignerPub);
142+
143+
const walletSigner = new bitgo.WalletUnspentSigner<RootWalletKeys>(keychains, signerKeychain, cosignerKeychain);
144+
signedTransaction = signAndVerifyWalletTransaction(tx, params.txInfo.unspents, walletSigner, {
145+
isLastSignature,
146+
}) as bitgo.UtxoTransaction<bigint>;
147+
}
148+
149+
return {
150+
txHex: signedTransaction.toBuffer().toString('hex'),
151+
};
152+
}

0 commit comments

Comments
 (0)