|
2 | 2 | * @prettier |
3 | 3 | */ |
4 | 4 | import { coins, EthLikeTokenConfig, tokens, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics'; |
| 5 | +import _ from 'lodash'; |
| 6 | +import { bip32 } from '@bitgo/utxo-lib'; |
5 | 7 |
|
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'; |
9 | 22 |
|
10 | 23 | export type CoinNames = { |
11 | 24 | [network: string]: string; |
@@ -149,6 +162,220 @@ export class EthLikeToken extends AbstractEthLikeNewCoins { |
149 | 162 | ]; |
150 | 163 | } |
151 | 164 |
|
| 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 | + |
152 | 379 | verifyCoin(txPrebuild: TransactionPrebuild): boolean { |
153 | 380 | return txPrebuild.coin === this.tokenConfig.coin && txPrebuild.token === this.tokenConfig.type; |
154 | 381 | } |
|
0 commit comments