Skip to content

Commit 0c074a9

Browse files
committed
fix(abstract-eth): fix recover token method for ethlike
WIN-2117 This commit adds recover token method for ethlike coins TICKET: WIN-2117
1 parent 775f81b commit 0c074a9

3 files changed

Lines changed: 260 additions & 4 deletions

File tree

modules/abstract-eth/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"ethers": "^5.1.3",
5656
"keccak": "^3.0.3",
5757
"lodash": "4.17.21",
58-
"secp256k1": "5.0.0"
58+
"secp256k1": "5.0.0",
59+
"superagent": "^3.8.3"
5960
}
6061
}

modules/abstract-eth/src/ethLikeToken.ts

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@
22
* @prettier
33
*/
44
import { coins, EthLikeTokenConfig, tokens, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics';
5+
import _ from 'lodash';
6+
import { bip32 } from '@bitgo/utxo-lib';
57

6-
import { BitGoBase, CoinConstructor, NamedCoinConstructor } from '@bitgo/sdk-core';
7-
import { TransactionBuilder as EthLikeTransactionBuilder } from './lib';
8-
import { AbstractEthLikeNewCoins, optionalDeps, TransactionPrebuild } from './abstractEthLikeNewCoins';
8+
import { BitGoBase, CoinConstructor, NamedCoinConstructor, getIsUnsignedSweep, Util } from '@bitgo/sdk-core';
9+
import {
10+
TransactionBuilder as EthLikeTransactionBuilder,
11+
TransferBuilder as EthLikeTransferBuilder,
12+
KeyPair as KeyPairLib,
13+
} from './lib';
14+
import {
15+
AbstractEthLikeNewCoins,
16+
optionalDeps,
17+
TransactionPrebuild,
18+
RecoverOptions,
19+
RecoveryInfo,
20+
OfflineVaultTxInfo,
21+
} from './abstractEthLikeNewCoins';
922

1023
export type CoinNames = {
1124
[network: string]: string;
@@ -149,6 +162,220 @@ export class EthLikeToken extends AbstractEthLikeNewCoins {
149162
];
150163
}
151164

165+
/**
166+
* Builds a token recovery transaction without BitGo
167+
* @param params
168+
* @param params.userKey {String} [encrypted] xprv
169+
* @param params.backupKey {String} [encrypted] xprv or xpub if the xprv is held by a KRS providers
170+
* @param params.walletPassphrase {String} used to decrypt userKey and backupKey
171+
* @param params.walletContractAddress {String} the ETH address of the wallet contract
172+
* @param params.recoveryDestination {String} target address to send recovered funds to
173+
* @param params.krsProvider {String} necessary if backup key is held by KRS
174+
* @param params.tokenContractAddress {String} contract address for token to recover
175+
*/
176+
async recover(params: RecoverOptions): Promise<RecoveryInfo | OfflineVaultTxInfo> {
177+
if (_.isUndefined(params.userKey)) {
178+
throw new Error('missing userKey');
179+
}
180+
181+
if (_.isUndefined(params.backupKey)) {
182+
throw new Error('missing backupKey');
183+
}
184+
185+
if (_.isUndefined(params.walletPassphrase) && !params.userKey.startsWith('xpub')) {
186+
throw new Error('missing wallet passphrase');
187+
}
188+
189+
if (_.isUndefined(params.walletContractAddress) || !this.isValidAddress(params.walletContractAddress)) {
190+
throw new Error('invalid walletContractAddress');
191+
}
192+
193+
if (_.isUndefined(params.tokenContractAddress) || !this.isValidAddress(params.tokenContractAddress)) {
194+
throw new Error('invalid tokenContractAddress');
195+
}
196+
197+
if (_.isUndefined(params.recoveryDestination) || !this.isValidAddress(params.recoveryDestination)) {
198+
throw new Error('invalid recoveryDestination');
199+
}
200+
201+
const isUnsignedSweep = getIsUnsignedSweep(params);
202+
203+
// Clean up whitespace from entered values
204+
let userKey = params.userKey.replace(/\s/g, '');
205+
const backupKey = params.backupKey.replace(/\s/g, '');
206+
207+
const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit));
208+
const gasPrice = params.eip1559
209+
? new optionalDeps.ethUtil.BN(params.eip1559.maxFeePerGas)
210+
: new optionalDeps.ethUtil.BN(this.setGasPrice(params.gasPrice));
211+
212+
// Decrypt private keys from KeyCard values
213+
if (!userKey.startsWith('xpub') && !userKey.startsWith('xprv')) {
214+
try {
215+
userKey = this.bitgo.decrypt({
216+
input: userKey,
217+
password: params.walletPassphrase,
218+
});
219+
} catch (e) {
220+
throw new Error(`Error decrypting user keychain: ${e.message}`);
221+
}
222+
}
223+
224+
let backupKeyAddress;
225+
let backupSigningKey;
226+
227+
if (isUnsignedSweep) {
228+
const backupHDNode = bip32.fromBase58(backupKey);
229+
backupSigningKey = backupHDNode.publicKey;
230+
backupKeyAddress = `0x${optionalDeps.ethUtil.publicToAddress(backupSigningKey, true).toString('hex')}`;
231+
} else {
232+
let backupPrv;
233+
234+
try {
235+
backupPrv = this.bitgo.decrypt({
236+
input: backupKey,
237+
password: params.walletPassphrase,
238+
});
239+
} catch (e) {
240+
throw new Error(`Error decrypting backup keychain: ${e.message}`);
241+
}
242+
243+
const keyPair = new KeyPairLib({ prv: backupPrv });
244+
backupSigningKey = keyPair.getKeys().prv;
245+
if (!backupSigningKey) {
246+
throw new Error('no private key');
247+
}
248+
backupKeyAddress = keyPair.getAddress();
249+
}
250+
251+
// Get nonce for backup key (should be 0)
252+
let backupKeyNonce = 0;
253+
254+
const result = await this.recoveryBlockchainExplorerQuery({
255+
module: 'account',
256+
action: 'txlist',
257+
address: backupKeyAddress,
258+
});
259+
260+
const backupKeyTxList = result.result;
261+
if (backupKeyTxList.length > 0) {
262+
// Calculate last nonce used
263+
const outgoingTxs = backupKeyTxList.filter((tx) => tx.from === backupKeyAddress);
264+
backupKeyNonce = outgoingTxs.length;
265+
}
266+
267+
// get balance of backup key and make sure we can afford gas
268+
const backupKeyBalance = await this.queryAddressBalance(backupKeyAddress);
269+
270+
if (backupKeyBalance.lt(gasPrice.mul(gasLimit))) {
271+
throw new Error(
272+
`Backup key address ${backupKeyAddress} has balance ${backupKeyBalance.toString(
273+
10
274+
)}. This address must have a balance of at least 0.01 ETH to perform recoveries`
275+
);
276+
}
277+
278+
// get token balance of wallet
279+
const txAmount = await this.queryAddressTokenBalance(
280+
params.tokenContractAddress as string,
281+
params.walletContractAddress
282+
);
283+
284+
// build recipients object
285+
const recipients = [
286+
{
287+
address: params.recoveryDestination,
288+
amount: txAmount.toString(10),
289+
},
290+
];
291+
292+
// Get sequence ID using contract call
293+
await new Promise((resolve) => setTimeout(resolve, 1000));
294+
const sequenceId = await this.querySequenceId(params.walletContractAddress);
295+
296+
let operationHash, signature;
297+
if (!isUnsignedSweep) {
298+
// Get operation hash and sign it
299+
operationHash = this.getOperationSha3ForExecuteAndConfirm(recipients, this.getDefaultExpireTime(), sequenceId);
300+
signature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userKey));
301+
302+
try {
303+
Util.ecRecoverEthAddress(operationHash, signature);
304+
} catch (e) {
305+
throw new Error('Invalid signature');
306+
}
307+
}
308+
309+
const txInfo = {
310+
recipient: recipients[0],
311+
expireTime: this.getDefaultExpireTime(),
312+
contractSequenceId: sequenceId,
313+
operationHash: operationHash,
314+
signature: signature,
315+
gasLimit: gasLimit.toString(10),
316+
tokenContractAddress: params.tokenContractAddress,
317+
};
318+
319+
const txBuilder = this.getTransactionBuilder() as EthLikeTransactionBuilder;
320+
txBuilder.counter(backupKeyNonce);
321+
txBuilder.contract(params.walletContractAddress);
322+
let txFee;
323+
if (params.eip1559) {
324+
txFee = {
325+
eip1559: {
326+
maxPriorityFeePerGas: params.eip1559.maxPriorityFeePerGas,
327+
maxFeePerGas: params.eip1559.maxFeePerGas,
328+
},
329+
};
330+
} else {
331+
txFee = { fee: gasPrice.toString() };
332+
}
333+
txBuilder.fee({
334+
...txFee,
335+
gasLimit: gasLimit.toString(),
336+
});
337+
const transferBuilder = txBuilder.transfer() as EthLikeTransferBuilder;
338+
transferBuilder
339+
.coin(this.tokenConfig.type)
340+
.amount(recipients[0].amount)
341+
.contractSequenceId(sequenceId)
342+
.expirationTime(this.getDefaultExpireTime())
343+
.to(params.recoveryDestination);
344+
345+
const tx = await txBuilder.build();
346+
347+
if (isUnsignedSweep) {
348+
const response: OfflineVaultTxInfo = {
349+
txHex: tx.toBroadcastFormat(),
350+
userKey,
351+
backupKey,
352+
coin: this.getChain(),
353+
gasPrice: optionalDeps.ethUtil.bufferToInt(gasPrice).toFixed(),
354+
gasLimit,
355+
recipients: [txInfo.recipient],
356+
walletContractAddress: tx.toJson().to,
357+
amount: txInfo.recipient.amount,
358+
backupKeyNonce,
359+
eip1559: params.eip1559,
360+
};
361+
_.extend(response, txInfo);
362+
response.nextContractSequenceId = response.contractSequenceId;
363+
return response;
364+
}
365+
366+
txBuilder
367+
.transfer()
368+
.coin(this.tokenConfig.type)
369+
.key(new KeyPairLib({ prv: userKey }).getKeys().prv as string);
370+
txBuilder.sign({ key: backupSigningKey });
371+
372+
const signedTx = await txBuilder.build();
373+
return {
374+
id: signedTx.toJson().id,
375+
tx: signedTx.toBroadcastFormat(),
376+
};
377+
}
378+
152379
verifyCoin(txPrebuild: TransactionPrebuild): boolean {
153380
return txPrebuild.coin === this.tokenConfig.coin && txPrebuild.token === this.tokenConfig.type;
154381
}

modules/abstract-eth/src/lib/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Buffer } from 'buffer';
2+
import request from 'superagent';
23
import assert from 'assert';
34
import {
45
addHexPrefix,
@@ -788,3 +789,30 @@ export function decodeForwarderCreationData(data: string): ForwarderInitializati
788789
} as const;
789790
}
790791
}
792+
793+
/**
794+
* Make a query to explorer for information such as balance, token balance, solidity calls
795+
* @param {Object} query key-value pairs of parameters to append after /api
796+
* @param {string} token the API token to use for the request
797+
* @param {string} explorerUrl the URL of the explorer
798+
* @returns {Promise<Object>} response from explorer
799+
*/
800+
export async function recoveryBlockchainExplorerQuery(
801+
query: Record<string, string>,
802+
explorerUrl: string,
803+
token?: string
804+
): Promise<Record<string, unknown>> {
805+
if (token) {
806+
query.apikey = token;
807+
}
808+
const response = await request.get(`${explorerUrl}/api`).query(query);
809+
810+
if (!response.ok) {
811+
throw new Error('could not reach explorer');
812+
}
813+
814+
if (response.body.status === '0' && response.body.message === 'NOTOK') {
815+
throw new Error('Explorer rate limit reached');
816+
}
817+
return response.body;
818+
}

0 commit comments

Comments
 (0)