Skip to content

Commit 394f6ee

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): extract fixed-script address generation
Move the fixed-script address generation and validation code from AbstractUtxoCoin into a separate module. This improves code organization and reuse, making the implementation more maintainable. Issue: BTC-2652 Co-authored-by: llm-git <[email protected]>
1 parent ae1335f commit 394f6ee

26 files changed

+333
-1251
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 7 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { bip32 } from '@bitgo/secp256k1';
77
import { bitgo, getMainnet, isMainnet, isTestnet } from '@bitgo/utxo-lib';
88
import {
99
AddressCoinSpecific,
10-
AddressTypeChainMismatchError,
1110
BaseCoin,
1211
BitGoBase,
1312
CreateAddressFormat,
@@ -25,24 +24,17 @@ import {
2524
MismatchedRecipient,
2625
MultisigType,
2726
multisigTypes,
28-
P2shP2wshUnsupportedError,
29-
P2trMusig2UnsupportedError,
30-
P2trUnsupportedError,
31-
P2wshUnsupportedError,
3227
ParseTransactionOptions as BaseParseTransactionOptions,
3328
PrecreateBitGoOptions,
3429
PresignTransactionOptions,
3530
RequestTracer,
36-
sanitizeLegacyPath,
3731
SignedTransaction,
3832
SignTransactionOptions as BaseSignTransactionOptions,
3933
SupplementGenerateWalletOptions,
4034
TransactionParams as BaseTransactionParams,
4135
TransactionPrebuild as BaseTransactionPrebuild,
4236
Triple,
4337
TxIntentMismatchRecipientError,
44-
UnexpectedAddressError,
45-
UnsupportedAddressTypeError,
4638
VerificationOptions,
4739
VerifyAddressOptions as BaseVerifyAddressOptions,
4840
VerifyTransactionOptions as BaseVerifyTransactionOptions,
@@ -81,6 +73,7 @@ import {
8173
} from './transaction/descriptor/verifyTransaction';
8274
import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptorWallet } from './descriptor';
8375
import { getChainFromNetwork, getFamilyFromNetwork, getFullNameFromNetwork } from './names';
76+
import { assertFixedScriptWalletAddress } from './address/fixedScript';
8477
import { CustomChangeOptions } from './transaction/fixedScript';
8578
import { toBip32Triple, UtxoKeychain, UtxoNamedKeychains } from './keychains';
8679
import { verifyKeySignature, verifyUserPublicKey } from './verifyKey';
@@ -116,7 +109,7 @@ type UtxoCustomSigningFunction<TNumber extends number | bigint> = {
116109
}): Promise<SignedTransaction>;
117110
};
118111

119-
const { getExternalChainCode, isChainCode, scriptTypeForChain, outputScripts } = bitgo;
112+
const { isChainCode, scriptTypeForChain, outputScripts } = bitgo;
120113

121114
type Unspent<TNumber extends number | bigint = number> = bitgo.Unspent<TNumber>;
122115

@@ -707,7 +700,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
707700
* @throws {UnexpectedAddressError}
708701
*/
709702
async isWalletAddress(params: VerifyAddressOptions<UtxoCoinSpecific>, wallet?: IWallet): Promise<boolean> {
710-
const { address, addressType, keychains, chain, index } = params;
703+
const { address, keychains, chain, index } = params;
711704

712705
if (!this.isValidAddress(address)) {
713706
throw new InvalidAddressError(`invalid address: ${address}`);
@@ -738,21 +731,15 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
738731
throw new Error('missing required param keychains');
739732
}
740733

741-
const expectedAddress = this.generateAddress({
742-
format: params.format,
743-
addressType: addressType as ScriptType2Of3,
734+
assertFixedScriptWalletAddress(this.network, {
735+
address,
744736
keychains,
745-
threshold: 2,
737+
format: params.format ?? 'base58',
738+
addressType: params.addressType,
746739
chain,
747740
index,
748741
});
749742

750-
if (expectedAddress.address !== address) {
751-
throw new UnexpectedAddressError(
752-
`address validation failure: expected ${expectedAddress.address} but got ${address}`
753-
);
754-
}
755-
756743
return true;
757744
}
758745

@@ -781,103 +768,6 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
781768
return [KeyIndices.USER, KeyIndices.BACKUP, KeyIndices.BITGO];
782769
}
783770

784-
/**
785-
* TODO(BG-11487): Remove addressType, segwit, and bech32 params in SDKv6
786-
* Generate an address for a wallet based on a set of configurations
787-
* @param params.addressType {string} Deprecated
788-
* @param params.keychains {[object]} Array of objects with xpubs
789-
* @param params.threshold {number} Minimum number of signatures
790-
* @param params.chain {number} Derivation chain (see https://github.com/BitGo/unspents/blob/master/src/codes.ts for
791-
* the corresponding address type of a given chain code)
792-
* @param params.index {number} Derivation index
793-
* @param params.segwit {boolean} Deprecated
794-
* @param params.bech32 {boolean} Deprecated
795-
* @returns {{chain: number, index: number, coin: number, coinSpecific: {outputScript, redeemScript}}}
796-
*/
797-
generateAddress(params: GenerateFixedScriptAddressOptions): AddressDetails {
798-
let derivationIndex = 0;
799-
if (_.isInteger(params.index) && (params.index as number) > 0) {
800-
derivationIndex = params.index as number;
801-
}
802-
803-
const { keychains, threshold, chain, segwit = false, bech32 = false } = params as GenerateFixedScriptAddressOptions;
804-
805-
let derivationChain = getExternalChainCode('p2sh');
806-
if (_.isNumber(chain) && _.isInteger(chain) && isChainCode(chain)) {
807-
derivationChain = chain;
808-
}
809-
810-
function convertFlagsToAddressType(): ScriptType2Of3 {
811-
if (isChainCode(chain)) {
812-
return utxolib.bitgo.scriptTypeForChain(chain);
813-
}
814-
if (_.isBoolean(segwit) && segwit) {
815-
return 'p2shP2wsh';
816-
} else if (_.isBoolean(bech32) && bech32) {
817-
return 'p2wsh';
818-
} else {
819-
return 'p2sh';
820-
}
821-
}
822-
823-
const addressType = params.addressType || convertFlagsToAddressType();
824-
825-
if (addressType !== utxolib.bitgo.scriptTypeForChain(derivationChain)) {
826-
throw new AddressTypeChainMismatchError(addressType, derivationChain);
827-
}
828-
829-
if (!this.supportsAddressType(addressType)) {
830-
switch (addressType) {
831-
case 'p2sh':
832-
throw new Error(`internal error: p2sh should always be supported`);
833-
case 'p2shP2wsh':
834-
throw new P2shP2wshUnsupportedError();
835-
case 'p2wsh':
836-
throw new P2wshUnsupportedError();
837-
case 'p2tr':
838-
throw new P2trUnsupportedError();
839-
case 'p2trMusig2':
840-
throw new P2trMusig2UnsupportedError();
841-
default:
842-
throw new UnsupportedAddressTypeError();
843-
}
844-
}
845-
846-
let signatureThreshold = 2;
847-
if (_.isInteger(threshold)) {
848-
signatureThreshold = threshold as number;
849-
if (signatureThreshold <= 0) {
850-
throw new Error('threshold has to be positive');
851-
}
852-
if (signatureThreshold > keychains.length) {
853-
throw new Error('threshold cannot exceed number of keys');
854-
}
855-
}
856-
857-
const path = '0/0/' + derivationChain + '/' + derivationIndex;
858-
const hdNodes = keychains.map(({ pub }) => bip32.fromBase58(pub));
859-
const derivedKeys = hdNodes.map((hdNode) => hdNode.derivePath(sanitizeLegacyPath(path)).publicKey);
860-
861-
const { outputScript, redeemScript, witnessScript, address } = this.createMultiSigAddress(
862-
addressType,
863-
signatureThreshold,
864-
derivedKeys
865-
);
866-
867-
return {
868-
address: this.canonicalAddress(address, params.format),
869-
chain: derivationChain,
870-
index: derivationIndex,
871-
coin: this.getChain(),
872-
coinSpecific: {
873-
outputScript: outputScript.toString('hex'),
874-
redeemScript: redeemScript && redeemScript.toString('hex'),
875-
witnessScript: witnessScript && witnessScript.toString('hex'),
876-
},
877-
addressType,
878-
};
879-
}
880-
881771
/**
882772
* @returns input psbt added with deterministic MuSig2 nonce for bitgo key for each MuSig2 inputs.
883773
* @param psbtHex all MuSig2 inputs should contain user MuSig2 nonce
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import _ from 'lodash';
2+
import {
3+
AddressTypeChainMismatchError,
4+
CreateAddressFormat,
5+
InvalidAddressDerivationPropertyError,
6+
UnexpectedAddressError,
7+
P2shP2wshUnsupportedError,
8+
P2trMusig2UnsupportedError,
9+
P2trUnsupportedError,
10+
P2wshUnsupportedError,
11+
UnsupportedAddressTypeError,
12+
sanitizeLegacyPath,
13+
} from '@bitgo/sdk-core';
14+
import * as utxolib from '@bitgo/utxo-lib';
15+
import { bitgo } from '@bitgo/utxo-lib';
16+
import { bip32 } from '@bitgo/secp256k1';
17+
18+
type ScriptType2Of3 = bitgo.outputScripts.ScriptType2Of3;
19+
20+
export interface FixedScriptAddressCoinSpecific {
21+
outputScript?: string;
22+
redeemScript?: string;
23+
witnessScript?: string;
24+
}
25+
26+
export interface GenerateAddressOptions {
27+
addressType?: ScriptType2Of3;
28+
threshold?: number;
29+
chain?: number;
30+
index?: number;
31+
segwit?: boolean;
32+
bech32?: boolean;
33+
}
34+
35+
export interface GenerateFixedScriptAddressOptions extends GenerateAddressOptions {
36+
format?: CreateAddressFormat;
37+
keychains: { pub: string }[];
38+
}
39+
40+
function canonicalAddress(network: utxolib.Network, address: string, format?: CreateAddressFormat): string {
41+
if (format === 'cashaddr') {
42+
const script = utxolib.addressFormat.toOutputScriptTryFormats(address, network);
43+
return utxolib.addressFormat.fromOutputScriptWithFormat(script, format, network);
44+
}
45+
// Default to canonical format (base58 for most coins)
46+
return utxolib.addressFormat.toCanonicalFormat(address, network);
47+
}
48+
49+
function supportsAddressType(network: utxolib.Network, addressType: ScriptType2Of3): boolean {
50+
return utxolib.bitgo.outputScripts.isSupportedScriptType(network, addressType);
51+
}
52+
53+
export function generateAddressWithChainAndIndex(
54+
network: utxolib.Network,
55+
keychains: { pub: string }[],
56+
chain: bitgo.ChainCode,
57+
index: number,
58+
format: CreateAddressFormat | undefined
59+
): string {
60+
const path = '0/0/' + chain + '/' + index;
61+
const hdNodes = keychains.map(({ pub }) => bip32.fromBase58(pub));
62+
const derivedKeys = hdNodes.map((hdNode) => hdNode.derivePath(sanitizeLegacyPath(path)).publicKey);
63+
const addressType = bitgo.scriptTypeForChain(chain);
64+
65+
const { scriptPubKey: outputScript } = utxolib.bitgo.outputScripts.createOutputScript2of3(derivedKeys, addressType);
66+
67+
const address = utxolib.address.fromOutputScript(outputScript, network);
68+
69+
return canonicalAddress(network, address, format);
70+
}
71+
72+
/**
73+
* Generate an address for a wallet based on a set of configurations
74+
* @param params.addressType {string} Deprecated
75+
* @param params.keychains {[object]} Array of objects with xpubs
76+
* @param params.threshold {number} Minimum number of signatures
77+
* @param params.chain {number} Derivation chain (see https://github.com/BitGo/unspents/blob/master/src/codes.ts for
78+
* the corresponding address type of a given chain code)
79+
* @param params.index {number} Derivation index
80+
* @param params.segwit {boolean} Deprecated
81+
* @param params.bech32 {boolean} Deprecated
82+
* @returns {string} The generated address
83+
*/
84+
export function generateAddress(network: utxolib.Network, params: GenerateFixedScriptAddressOptions): string {
85+
let derivationIndex = 0;
86+
if (_.isInteger(params.index) && (params.index as number) > 0) {
87+
derivationIndex = params.index as number;
88+
}
89+
90+
const { keychains, threshold, chain, segwit = false, bech32 = false } = params as GenerateFixedScriptAddressOptions;
91+
92+
let derivationChain = bitgo.getExternalChainCode('p2sh');
93+
if (_.isNumber(chain) && _.isInteger(chain) && bitgo.isChainCode(chain)) {
94+
derivationChain = chain;
95+
}
96+
97+
function convertFlagsToAddressType(): ScriptType2Of3 {
98+
if (bitgo.isChainCode(chain)) {
99+
return bitgo.scriptTypeForChain(chain);
100+
}
101+
if (_.isBoolean(segwit) && segwit) {
102+
return 'p2shP2wsh';
103+
} else if (_.isBoolean(bech32) && bech32) {
104+
return 'p2wsh';
105+
} else {
106+
return 'p2sh';
107+
}
108+
}
109+
110+
const addressType = params.addressType || convertFlagsToAddressType();
111+
112+
if (addressType !== utxolib.bitgo.scriptTypeForChain(derivationChain)) {
113+
throw new AddressTypeChainMismatchError(addressType, derivationChain);
114+
}
115+
116+
if (!supportsAddressType(network, addressType)) {
117+
switch (addressType) {
118+
case 'p2sh':
119+
throw new Error(`internal error: p2sh should always be supported`);
120+
case 'p2shP2wsh':
121+
throw new P2shP2wshUnsupportedError();
122+
case 'p2wsh':
123+
throw new P2wshUnsupportedError();
124+
case 'p2tr':
125+
throw new P2trUnsupportedError();
126+
case 'p2trMusig2':
127+
throw new P2trMusig2UnsupportedError();
128+
default:
129+
throw new UnsupportedAddressTypeError();
130+
}
131+
}
132+
133+
let signatureThreshold = 2;
134+
if (_.isInteger(threshold)) {
135+
signatureThreshold = threshold as number;
136+
if (signatureThreshold <= 0) {
137+
throw new Error('threshold has to be positive');
138+
}
139+
if (signatureThreshold > keychains.length) {
140+
throw new Error('threshold cannot exceed number of keys');
141+
}
142+
}
143+
144+
return generateAddressWithChainAndIndex(network, keychains, derivationChain, derivationIndex, params.format);
145+
}
146+
147+
type Keychain = {
148+
pub: string;
149+
};
150+
151+
export function assertFixedScriptWalletAddress(
152+
network: utxolib.Network,
153+
{
154+
chain,
155+
index,
156+
keychains,
157+
format,
158+
addressType,
159+
address,
160+
}: {
161+
chain: number | undefined;
162+
index: number;
163+
keychains: Keychain[];
164+
format: CreateAddressFormat;
165+
addressType: string | undefined;
166+
address: string;
167+
}
168+
): void {
169+
if ((_.isUndefined(chain) && _.isUndefined(index)) || !(_.isFinite(chain) && _.isFinite(index))) {
170+
throw new InvalidAddressDerivationPropertyError(
171+
`address validation failure: invalid chain (${chain}) or index (${index})`
172+
);
173+
}
174+
175+
if (!keychains) {
176+
throw new Error('missing required param keychains');
177+
}
178+
179+
const expectedAddress = generateAddress(network, {
180+
format,
181+
addressType: addressType as ScriptType2Of3,
182+
keychains,
183+
threshold: 2,
184+
chain,
185+
index,
186+
});
187+
188+
if (expectedAddress !== address) {
189+
throw new UnexpectedAddressError(`address validation failure: expected ${expectedAddress} but got ${address}`);
190+
}
191+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
generateAddress,
3+
generateAddressWithChainAndIndex,
4+
assertFixedScriptWalletAddress,
5+
FixedScriptAddressCoinSpecific,
6+
} from './fixedScript';

0 commit comments

Comments
 (0)