Skip to content

chore(statics): update old forwarder factory contracts #6592

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 225 additions & 1 deletion modules/sdk-coin-eth/src/hteth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { BaseCoin, BitGoBase } from '@bitgo/sdk-core';
import { BaseCoin, BitGoBase, getIsUnsignedSweep, Util } from '@bitgo/sdk-core';
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
import { Eth } from './eth';
import { bip32 } from '@bitgo/secp256k1';
import {
KeyPair as KeyPairLib,
OfflineVaultTxInfo,
optionalDeps,
RecoverOptions,
RecoveryInfo,
SignedTransaction,
SignTransactionOptions,
TransactionBuilder,
TransferBuilder,
} from '@bitgo/abstract-eth';
import { BigNumber } from 'bignumber.js';
import _ from 'lodash';

export class Hteth extends Eth {
protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
Expand All @@ -10,4 +24,214 @@ export class Hteth extends Eth {
static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>): BaseCoin {
return new Hteth(bitgo, staticsCoin);
}

protected async recoverEthLike(params: RecoverOptions): Promise<RecoveryInfo | OfflineVaultTxInfo> {
// bitgoFeeAddress is only defined when it is a evm cross chain recovery
// as we use fee from this wrong chain address for the recovery txn on the correct chain.
if (params.bitgoFeeAddress) {
return this.recoverEthLikeforEvmBasedRecovery(params);
}

this.validateRecoveryParams(params);
const isUnsignedSweep = params.isUnsignedSweep ?? getIsUnsignedSweep(params);

// Clean up whitespace from entered values
let userKey = params.userKey.replace(/\s/g, '');
const backupKey = params.backupKey.replace(/\s/g, '');
const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit));
const gasPrice = params.eip1559
? new optionalDeps.ethUtil.BN(params.eip1559.maxFeePerGas)
: new optionalDeps.ethUtil.BN(this.setGasPrice(params.gasPrice));

if (!userKey.startsWith('xpub') && !userKey.startsWith('xprv')) {
try {
userKey = this.bitgo.decrypt({
input: userKey,
password: params.walletPassphrase,
});
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e.message}`);
}
}
let backupKeyAddress;
let backupSigningKey;
if (isUnsignedSweep) {
const backupHDNode = bip32.fromBase58(backupKey);
backupSigningKey = backupHDNode.publicKey;
backupKeyAddress = `0x${optionalDeps.ethUtil.publicToAddress(backupSigningKey, true).toString('hex')}`;
} else {
// Decrypt backup private key and get address
let backupPrv;

try {
backupPrv = this.bitgo.decrypt({
input: backupKey,
password: params.walletPassphrase,
});
} catch (e) {
throw new Error(`Error decrypting backup keychain: ${e.message}`);
}

const keyPair = new KeyPairLib({ prv: backupPrv });
backupSigningKey = keyPair.getKeys().prv;
if (!backupSigningKey) {
throw new Error('no private key');
}
backupKeyAddress = keyPair.getAddress();
}

const backupKeyNonce = await this.getAddressNonce(backupKeyAddress, params.apiKey);
// get balance of backupKey to ensure funds are available to pay fees
const backupKeyBalance = await this.queryAddressBalance(backupKeyAddress, params.apiKey);
const totalGasNeeded = gasPrice.mul(gasLimit);

const weiToGwei = 10 ** 9;
if (backupKeyBalance.lt(totalGasNeeded)) {
throw new Error(
`Backup key address ${backupKeyAddress} has balance ${(backupKeyBalance / weiToGwei).toString()} Gwei.` +
`This address must have a balance of at least ${(totalGasNeeded / weiToGwei).toString()}` +
` Gwei to perform recoveries. Try sending some funds to this address then retry.`
);
}

// get balance of wallet
const txAmount = await this.queryAddressBalance(params.walletContractAddress, params.apiKey);
if (new BigNumber(txAmount).isLessThanOrEqualTo(0)) {
throw new Error('Wallet does not have enough funds to recover');
}

// build recipients object
const recipients = [
{
address: params.recoveryDestination,
amount: txAmount.toString(10),
},
];

// Get sequence ID using contract call
// we need to wait between making two explorer api calls to avoid getting banned
await new Promise((resolve) => setTimeout(resolve, 1000));
const sequenceId = await this.querySequenceId(params.walletContractAddress, params.apiKey);

let operationHash, signature;
// Get operation hash and sign it
if (!isUnsignedSweep) {
operationHash = this.getOperationSha3ForExecuteAndConfirm(recipients, this.getDefaultExpireTime(), sequenceId);
signature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userKey));

try {
Util.ecRecoverEthAddress(operationHash, signature);
} catch (e) {
throw new Error('Invalid signature');
}
}

const txInfo = {
recipient: recipients[0],
expireTime: this.getDefaultExpireTime(),
contractSequenceId: sequenceId,
operationHash: operationHash,
signature: signature,
gasLimit: gasLimit.toString(10),
};

const txBuilder = this.getTransactionBuilder() as TransactionBuilder;
txBuilder.counter(backupKeyNonce);
txBuilder.contract(params.walletContractAddress);
let txFee;
if (params.eip1559) {
txFee = {
eip1559: {
maxPriorityFeePerGas: params.eip1559.maxPriorityFeePerGas,
maxFeePerGas: params.eip1559.maxFeePerGas,
},
};
} else {
txFee = { fee: gasPrice.toString() };
}
txBuilder.fee({
...txFee,
gasLimit: gasLimit.toString(),
});
const transferBuilder = txBuilder.transfer() as TransferBuilder;
transferBuilder
.coin(this.staticsCoin?.name as string)
.amount(recipients[0].amount)
.contractSequenceId(sequenceId)
.expirationTime(this.getDefaultExpireTime())
.to(params.recoveryDestination);

const tx = await txBuilder.build();
if (isUnsignedSweep) {
const response: OfflineVaultTxInfo = {
txHex: tx.toBroadcastFormat(),
userKey,
backupKey,
coin: this.getChain(),
gasPrice: optionalDeps.ethUtil.bufferToInt(gasPrice).toFixed(),
gasLimit,
recipients: [txInfo.recipient],
walletContractAddress: tx.toJson().to,
amount: txInfo.recipient.amount,
backupKeyNonce,
eip1559: params.eip1559,
};
_.extend(response, txInfo);
response.nextContractSequenceId = response.contractSequenceId;
return response;
}

txBuilder
.transfer()
.coin(this.staticsCoin?.name as string)
.key(new KeyPairLib({ prv: userKey }).getKeys().prv as string);
txBuilder.sign({ key: backupSigningKey });

const signedTx = await txBuilder.build();

return {
id: signedTx.toJson().id,
tx: signedTx.toBroadcastFormat(),
};
}

async signTransaction(params: SignTransactionOptions): Promise<SignedTransaction> {
// Normally the SDK provides the first signature for an EthLike tx, but occasionally it provides the second and final one.
if (params.isLastSignature) {
// In this case when we're doing the second (final) signature, the logic is different.
return await this.signFinalEthLike(params);
}
const txBuilder = this.getTransactionBuilder();
txBuilder.from(params.txPrebuild.txHex);
txBuilder
.transfer()
.coin(this.staticsCoin?.name as string)
.key(new KeyPairLib({ prv: params.prv }).getKeys().prv!);
if (params.walletVersion) {
txBuilder.walletVersion(params.walletVersion);
}
const transaction = await txBuilder.build();

// In case of tx with contract data from a custodial wallet, we are running into an issue
// as halfSigned is not having the data field. So, we are adding the data field to the halfSigned tx
let recipients = params.txPrebuild.recipients || params.recipients;
if (recipients === undefined) {
recipients = transaction.outputs.map((output) => ({ address: output.address, amount: output.value }));
}

const txParams = {
eip1559: params.txPrebuild.eip1559,
txHex: transaction.toBroadcastFormat(),
recipients: recipients,
expiration: params.txPrebuild.expireTime,
hopTransaction: params.txPrebuild.hopTransaction,
custodianTransactionId: params.custodianTransactionId,
expireTime: params.expireTime,
contractSequenceId: params.txPrebuild.nextContractSequenceId as number,
sequenceId: params.sequenceId,
...(params.txPrebuild.isBatch ? { isBatch: params.txPrebuild.isBatch } : {}),
};

return { halfSigned: txParams };
}
}
5 changes: 5 additions & 0 deletions modules/sdk-coin-eth/src/lib/transferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export class TransferBuilder extends EthTransferBuilder {
* @returns the string prefix
*/
protected getNativeOperationHashPrefix(): string {
// TODO: if testnet, return '560048'
// else, return 'ETHER'
if (this._chainId === '560048') {
return '560048';
}
return 'ETHER';
}
}
34 changes: 34 additions & 0 deletions modules/sdk-coin-eth/test/unit/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,40 @@ describe('ETH:', function () {
basecoin = bitgo.coin('hteth') as Hteth;
});

it('should construct a recovery transaction without BitGo', async function () {
const basecoin = bitgo.coin('hteth');
const recovery = await (basecoin as any).recover({
userKey:
'{"iv":"aqsg7l4QYJuQ1MgJ4TYJQg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' +
':"ccm","adata":"","cipher":"aes","salt":"I9terSX4xiI=","ct":"hqg1i8pZHtjo8+\n' +
'kjIu5AsO782ddwQ5CGerz7zbEoSbFkVcMzQtrf6BYD+XpaYUUw8c/hSR3vlwvquGNoFDCXna355\n' +
'pQUYLLY7GDgDD33oznFj3HrP5y2acudAStuPkxz5Eu34WUnYac15Tts9oFaCO8vMgWgbYo="}',
backupKey:
'{"iv":"8zW3vgZRkHLKxNj9SXNm8g==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' +
':"ccm","adata":"","cipher":"aes","salt":"T8R4dMWbU2s=","ct":"J3eXZKbdIrqvXd\n' +
'3h/0enOQe2orKZPCI7fYqkMyPOQRiFVp2b9k790lM2arJ9lt4yM1aTAcRIlJ9ypVMdHppZreGZ0\n' +
'TOxtXg3I3yF8BHPdv0QrlR/ok6m67YHsqoY23OqnRiuAjR93DlWcHDDXrz7XF+xVmwdCcI="}',
walletContractAddress: '0xda7d06fb9c0369e824ed6ebd6b1c07fcd3b7ad1c',
walletPassphrase: 'kLghyj07=b5cBY;^q7Z1',
recoveryDestination: '0xDb2Dc32d8ade2bc922b4A46714C87282B1BD9d65',
apiKey: 'RBXDGS4EY2HM3A2MC89FCU2YTHCDX6QUIW',
eip1559: {
maxFeePerGas: 20000000000,
maxPriorityFeePerGas: 10000000000,
},
replayProtectionOptions: {
chain: '560048',
hardfork: 'london',
},
});

// id and tx will always be different because of expireTime
should.exist(recovery);
console.log('recovery', recovery);
recovery.should.have.property('id');
recovery.should.have.property('tx');
});

it('should generate an unsigned sweep without derivation seed', async function () {
nock(baseUrl)
.get('/api')
Expand Down
1 change: 1 addition & 0 deletions modules/statics/src/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export const coins = CoinMap.fromCoins([
CoinFeature.STUCK_TRANSACTION_MANAGEMENT_TSS,
CoinFeature.EIP1559,
CoinFeature.ERC20_BULK_TRANSACTION,
CoinFeature.USES_NON_PACKED_ENCODING_FOR_TXDATA,
]
),
account(
Expand Down
4 changes: 2 additions & 2 deletions modules/statics/src/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,8 +627,8 @@ class Hoodi extends Testnet implements EthereumNetwork {
// https://chainlist.org/chain/560048
chainId = 560048;
batcherContractAddress = '0x3e1e5d78e44f15593b3b61ed278f12c27f0ff33e';
forwarderFactoryAddress = '0xffa397285ce46fb78c588a9e993286aac68c37cd';
forwarderImplementationAddress = '0x059ffafdc6ef594230de44f824e2bd0a51ca5ded';
forwarderFactoryAddress = '0x7441f20a59be97011842404b9aefd8d85fd81aa6';
forwarderImplementationAddress = '0x0e2874d6824fae4f61e446012317a9b86384bd8e';
nativeCoinOperationHashPrefix = 'ETHER';
tokenOperationHashPrefix = 'ERC20';
}
Expand Down
4 changes: 2 additions & 2 deletions modules/statics/test/unit/resources/amsTokenConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,8 @@ export const amsTokenConfigWithCustomToken = {
blockExplorerUrl: 'https://hoodi.etherscan.io/block/',
chainId: 560048,
batcherContractAddress: '0x3e1e5d78e44f15593b3b61ed278f12c27f0ff33e',
forwarderFactoryAddress: '0xffa397285ce46fb78c588a9e993286aac68c37cd',
forwarderImplementationAddress: '0x059ffafdc6ef594230de44f824e2bd0a51ca5ded',
forwarderFactoryAddress: '0x7441f20a59be97011842404b9aefd8d85fd81aa6',
forwarderImplementationAddress: '0x0e2874d6824fae4f61e446012317a9b86384bd8e',
nativeCoinOperationHashPrefix: 'ETHER',
tokenOperationHashPrefix: 'ERC20',
},
Expand Down
Loading