Skip to content

Commit 302fd49

Browse files
committed
feat(sdk-coin-bsc): added support for mpcv2 in recovery
TICKET: WIN-4618
1 parent c4cd6c8 commit 302fd49

File tree

6 files changed

+581
-50
lines changed

6 files changed

+581
-50
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export type RecoverOptions = {
253253
intendedChain?: string;
254254
common?: EthLikeCommon.default;
255255
derivationSeed?: string;
256+
apiKey?: string;
256257
} & TSSRecoverOptions;
257258

258259
export type GetBatchExecutionInfoRT = {
@@ -531,12 +532,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
531532
* @param {String} address - the ETHLike address
532533
* @returns {BigNumber} address balance
533534
*/
534-
async queryAddressBalance(address: string): Promise<any> {
535-
const result = await this.recoveryBlockchainExplorerQuery({
536-
module: 'account',
537-
action: 'balance',
538-
address: address,
539-
});
535+
async queryAddressBalance(address: string, apiKey?: string): Promise<any> {
536+
const result = await this.recoveryBlockchainExplorerQuery(
537+
{
538+
module: 'account',
539+
action: 'balance',
540+
address: address,
541+
},
542+
apiKey
543+
);
540544
// throw if the result does not exist or the result is not a valid number
541545
if (!result || !result.result || isNaN(result.result)) {
542546
throw new Error(`Could not obtain address balance for ${address} from the explorer, got: ${result.result}`);
@@ -837,15 +841,18 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
837841
* @param {string} address
838842
* @returns {Promise<number>}
839843
*/
840-
async getAddressNonce(address: string): Promise<number> {
844+
async getAddressNonce(address: string, apiKey?: string): Promise<number> {
841845
// Get nonce for backup key (should be 0)
842846
let nonce = 0;
843847

844-
const result = await this.recoveryBlockchainExplorerQuery({
845-
module: 'account',
846-
action: 'txlist',
847-
address,
848-
});
848+
const result = await this.recoveryBlockchainExplorerQuery(
849+
{
850+
module: 'account',
851+
action: 'txlist',
852+
address,
853+
},
854+
apiKey
855+
);
849856
if (!result || !Array.isArray(result.result)) {
850857
throw new Error('Unable to find next nonce from Etherscan, got: ' + JSON.stringify(result));
851858
}
@@ -1932,7 +1939,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
19321939
}
19331940
}
19341941

1935-
private async getGasValues(params: RecoverOptions): Promise<{ gasLimit: number; gasPrice: Buffer }> {
1942+
protected async getGasValues(params: RecoverOptions): Promise<{ gasLimit: number; gasPrice: Buffer }> {
19361943
const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit));
19371944
const gasPrice = params.eip1559
19381945
? new optionalDeps.ethUtil.BN(params.eip1559.maxFeePerGas)
@@ -2152,9 +2159,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
21522159
};
21532160
}
21542161

2155-
private async buildTssRecoveryTxn(baseAddress: string, gasPrice: any, gasLimit: any, params: RecoverOptions) {
2156-
const nonce = await this.getAddressNonce(baseAddress);
2157-
const txAmount = await this.validateBalanceAndGetTxAmount(baseAddress, gasPrice, gasLimit);
2162+
protected async buildTssRecoveryTxn(baseAddress: string, gasPrice: any, gasLimit: any, params: RecoverOptions) {
2163+
const nonce = await this.getAddressNonce(baseAddress, params.apiKey);
2164+
const txAmount = await this.validateBalanceAndGetTxAmount(baseAddress, gasPrice, gasLimit, params.apiKey);
21582165
const recipients = [
21592166
{
21602167
address: params.recoveryDestination,
@@ -2183,8 +2190,8 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
21832190
return { txInfo, tx, nonce };
21842191
}
21852192

2186-
async validateBalanceAndGetTxAmount(baseAddress: string, gasPrice: BN, gasLimit: BN) {
2187-
const baseAddressBalance = await this.queryAddressBalance(baseAddress);
2193+
async validateBalanceAndGetTxAmount(baseAddress: string, gasPrice: BN, gasLimit: BN, apiKey?: string) {
2194+
const baseAddressBalance = await this.queryAddressBalance(baseAddress, apiKey);
21882195
const totalGasNeeded = gasPrice.mul(gasLimit);
21892196
const weiToGwei = new BN(10 ** 9);
21902197
if (baseAddressBalance.lt(totalGasNeeded)) {
@@ -2198,7 +2205,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
21982205
return txAmount;
21992206
}
22002207

2201-
async recoveryBlockchainExplorerQuery(query: Record<string, string>): Promise<any> {
2208+
async recoveryBlockchainExplorerQuery(query: Record<string, string>, apiKey?: string): Promise<any> {
22022209
throw new Error('method not implemented');
22032210
}
22042211

modules/sdk-coin-bsc/src/bsc.ts

Lines changed: 200 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
1-
import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core';
1+
import {
2+
BaseCoin,
3+
BitGoBase,
4+
common,
5+
Ecdsa,
6+
MPCAlgorithm,
7+
MultisigType,
8+
multisigTypes,
9+
UnsignedTransactionTss,
10+
} from '@bitgo/sdk-core';
211
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
312
import {
413
AbstractEthLikeNewCoins,
14+
KeyPair,
515
recoveryBlockchainExplorerQuery,
6-
VerifyEthTransactionOptions,
16+
ReplayProtectionOptions,
17+
UnsignedSweepTxMPCv2,
18+
RecoverOptions,
19+
OfflineVaultTxInfo,
20+
ETHTransactionType,
21+
Transaction as EthTransaction,
22+
optionalDeps,
723
} from '@bitgo/abstract-eth';
824
import { TransactionBuilder } from './lib';
25+
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
26+
import EthereumCommon from '@ethereumjs/common';
927

1028
export class Bsc extends AbstractEthLikeNewCoins {
1129
protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
@@ -16,6 +34,180 @@ export class Bsc extends AbstractEthLikeNewCoins {
1634
return new Bsc(bitgo, staticsCoin);
1735
}
1836

37+
/**
38+
* Builds an unsigned sweep transaction for TSS specific to BSC
39+
* This implementation ensures that only legacy transactions are supported for BSC.
40+
* @param params - Recovery options
41+
* @returns {Promise<OfflineVaultTxInfo | UnsignedSweepTxMPCv2>}
42+
*/
43+
protected async buildUnsignedSweepTxnTSS(params: RecoverOptions): Promise<OfflineVaultTxInfo | UnsignedSweepTxMPCv2> {
44+
const bscParams: RecoverOptions = { ...params };
45+
46+
if (!bscParams.replayProtectionOptions) {
47+
bscParams.replayProtectionOptions = {
48+
chain: this.getChain().includes('t') ? '97' : '56',
49+
hardfork: 'petersburg',
50+
};
51+
} else if (!bscParams.replayProtectionOptions.hardfork) {
52+
bscParams.replayProtectionOptions.hardfork = 'petersburg';
53+
}
54+
55+
if (!bscParams.gasLimit || !bscParams.gasPrice) {
56+
throw new Error('gasLimit and gasPrice are required for BSC legacy transactions');
57+
}
58+
59+
if (!bscParams.backupKey) {
60+
throw new Error('backupKey is required for TSS recovery');
61+
}
62+
if (!bscParams.recoveryDestination) {
63+
throw new Error('Recipient address (recoveryDestination) is required for TSS recovery');
64+
}
65+
66+
const { gasLimit, gasPrice } = await this.getGasValues(params);
67+
68+
const derivationPath = bscParams.derivationSeed ? getDerivationPath(bscParams.derivationSeed) : 'm/0';
69+
const MPC = new Ecdsa();
70+
const derivedCommonKeyChain = MPC.deriveUnhardened(bscParams.backupKey as string, derivationPath);
71+
const backupKeyPair = new KeyPair({ pub: derivedCommonKeyChain.slice(0, 66) });
72+
const baseAddress = backupKeyPair.getAddress();
73+
74+
const { txInfo, tx, nonce } = await this.buildTssRecoveryTxn(baseAddress, gasPrice, gasLimit, bscParams);
75+
76+
return this.buildTxRequestForOfflineVaultMPCv2bsc(
77+
txInfo,
78+
tx,
79+
derivationPath,
80+
nonce,
81+
gasPrice,
82+
gasLimit,
83+
bscParams.replayProtectionOptions,
84+
derivedCommonKeyChain
85+
);
86+
}
87+
/**
88+
* This transforms the unsigned transaction information into a format the BitGo offline vault expects
89+
* Specific to BSC which only supports legacy transactions
90+
* @param {any} txInfo
91+
* @param {any} tx
92+
* @param {string} derivationPath
93+
* @param {number} nonce
94+
* @param {string} gasPrice
95+
* @param {string} gasLimit
96+
* @param {undefined} _eip1559Params
97+
* @param {ReplayProtectionOptions} replayProtectionOptions
98+
* @param {string} commonKeyChain
99+
* @returns {UnsignedSweepTxMPCv2}
100+
*/
101+
private buildTxRequestForOfflineVaultMPCv2bsc(
102+
txInfo: any,
103+
tx: any,
104+
derivationPath: string,
105+
nonce: any,
106+
gasPrice: Buffer,
107+
gasLimit: number,
108+
replayProtectionOptions: ReplayProtectionOptions | undefined,
109+
commonKeyChain: string
110+
): OfflineVaultTxInfo | UnsignedSweepTxMPCv2 {
111+
if (!tx.to) {
112+
throw new Error('BSC tx must have a `to` address');
113+
}
114+
115+
console.log('tx', tx);
116+
const fee = gasLimit * Number(optionalDeps.ethUtil.bufferToInt(gasPrice).toFixed());
117+
const txNonce = tx.nonce;
118+
const txGasPrice = tx.gasPrice;
119+
const txGasLimit = tx.gasLimit;
120+
const txTo = tx.to ? tx.to.toBuffer() : Buffer.alloc(0);
121+
const txValue = tx.value;
122+
const txData = tx.data && tx.data.length > 0 ? tx.data : Buffer.alloc(0);
123+
const txFrom = tx.from ? tx.from.toBuffer() : Buffer.alloc(0);
124+
125+
const txChainId = this.getChain().includes('t') ? 97 : 56;
126+
127+
let signableHex: string;
128+
let serializedTxHex: string;
129+
130+
try {
131+
const txDataObj = {
132+
nonce: txNonce,
133+
gasPrice: txGasPrice,
134+
gasLimit: txGasLimit,
135+
to: txTo,
136+
value: txValue,
137+
data: txData,
138+
chainId: txChainId.toString(),
139+
_type: ETHTransactionType.LEGACY,
140+
from: txFrom,
141+
};
142+
143+
const common = EthereumCommon.forCustomChain(
144+
'mainnet',
145+
{
146+
name: 'bsc',
147+
networkId: txChainId,
148+
chainId: txChainId,
149+
},
150+
'petersburg'
151+
);
152+
153+
const bscTx = new EthTransaction(coins.get(this.getChain()), common, txDataObj);
154+
const serializedTx = bscTx.toBroadcastFormat();
155+
// Remove '0x' prefix if present
156+
serializedTxHex = serializedTx.startsWith('0x') ? serializedTx.slice(2) : serializedTx;
157+
signableHex = serializedTxHex;
158+
} catch (e) {
159+
throw new Error(`Failed to encode transaction: ${e.message}`);
160+
}
161+
162+
if (!replayProtectionOptions) {
163+
replayProtectionOptions = {
164+
chain: txChainId.toString(),
165+
hardfork: 'petersburg',
166+
};
167+
} else if (!replayProtectionOptions.chain) {
168+
replayProtectionOptions.chain = txChainId.toString();
169+
}
170+
171+
const unsignedTx: UnsignedTransactionTss = {
172+
serializedTxHex: serializedTxHex,
173+
signableHex: signableHex,
174+
derivationPath: derivationPath,
175+
feeInfo: {
176+
fee: fee,
177+
feeString: fee.toString(),
178+
},
179+
parsedTx: {
180+
spendAmount: txInfo.recipient.amount,
181+
outputs: [
182+
{
183+
coinName: this.getChain(),
184+
address: txInfo.recipient.address,
185+
valueString: txInfo.recipient.amount,
186+
},
187+
],
188+
},
189+
coinSpecific: {
190+
commonKeyChain: commonKeyChain,
191+
},
192+
replayProtectionOptions: replayProtectionOptions,
193+
};
194+
195+
return {
196+
txRequests: [
197+
{
198+
walletCoin: this.getChain(),
199+
transactions: [
200+
{
201+
unsignedTx: unsignedTx,
202+
nonce: nonce,
203+
signatureShares: [],
204+
},
205+
],
206+
},
207+
],
208+
};
209+
}
210+
19211
protected getTransactionBuilder(): TransactionBuilder {
20212
return new TransactionBuilder(coins.get(this.getBaseChain()));
21213
}
@@ -40,8 +232,11 @@ export class Bsc extends AbstractEthLikeNewCoins {
40232
return 'ecdsa';
41233
}
42234

43-
async recoveryBlockchainExplorerQuery(query: Record<string, string>): Promise<Record<string, unknown>> {
44-
const apiToken = common.Environments[this.bitgo.getEnv()].bscscanApiToken;
235+
async recoveryBlockchainExplorerQuery(
236+
query: Record<string, string>,
237+
apiKey?: string
238+
): Promise<Record<string, unknown>> {
239+
const apiToken = apiKey || common.Environments[this.bitgo.getEnv()].bscscanApiToken;
45240
const explorerUrl = common.Environments[this.bitgo.getEnv()].bscscanBaseUrl;
46241
return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken);
47242
}
@@ -55,7 +250,7 @@ export class Bsc extends AbstractEthLikeNewCoins {
55250
* @param {Wallet} params.wallet - Wallet object to obtain keys to verify against
56251
* @returns {boolean}
57252
*/
58-
async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
253+
async verifyTssTransaction(params: { txParams: any; txPrebuild: any; wallet: any }): Promise<boolean> {
59254
const { txParams, txPrebuild, wallet } = params;
60255
if (
61256
!txParams?.recipients &&

modules/sdk-coin-bsc/src/lib/transactionBuilder.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2-
import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core';
2+
import { BuildTransactionError, PublicKey, TransactionType } from '@bitgo/sdk-core';
33
import { TransactionBuilder as AbstractTransactionBuilder, Transaction } from '@bitgo/abstract-eth';
44
import { getCommon } from './utils';
55
import { TransferBuilder } from './transferBuilder';
66

77
export class TransactionBuilder extends AbstractTransactionBuilder {
88
protected _transfer: TransferBuilder;
9+
private _signatures: { publicKey: string; signature: string }[] = [];
910

1011
constructor(_coinConfig: Readonly<CoinConfig>) {
1112
super(_coinConfig);
@@ -23,4 +24,13 @@ export class TransactionBuilder extends AbstractTransactionBuilder {
2324
}
2425
return this._transfer;
2526
}
27+
/**
28+
* Add a signature to the transaction
29+
* @param publicKey - The public key associated with the signature
30+
* @param signature - The signature to add
31+
*/
32+
addSignature(publicKey: PublicKey, signature: Buffer): void {
33+
// Method updated
34+
this._signatures.push({ publicKey: publicKey.toString(), signature: signature.toString('hex') });
35+
}
2636
}

0 commit comments

Comments
 (0)