Skip to content
Open
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
142 changes: 142 additions & 0 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ import {
getRawDecoded,
getToken,
KeyPair as KeyPairLib,
sendMultiSigData,
sendMultiSigTokenData,
TransactionBuilder,
TransferBuilder,
} from './lib';
Expand Down Expand Up @@ -218,6 +220,8 @@ export interface OfflineVaultTxInfo {
feesUsed?: FeesUsed;
isEvmBasedCrossChainRecovery?: boolean;
walletVersion?: number;
expireTime?: number;
operationHash?: string;
}

interface UnformattedTxInfo {
Expand Down Expand Up @@ -1637,6 +1641,144 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
};
}

/**
* Injects a user signature into recovery calldata and builds an unsigned EIP-1559 (or legacy) tx
* for the backup key to sign. Used by external signing flows (e.g. AWM) during multisig recovery
* when isUnsignedSweep is true.
*
* @param unsignedSweepPrebuildTx - OfflineVaultTxInfo from recover() with isUnsignedSweep
* @param userSignature - ECDSA signature of the operation hash from the user key
* @returns txHash for the backup key to sign, and unsigned txHex for finalizeRecoveryTx
*/
async buildRecoveryTxForBackupSigning(
unsignedSweepPrebuildTx: OfflineVaultTxInfo,
userSignature: string
): Promise<{ txHash: string; txHex: string }> {
if (!userSignature) {
throw new Error('missing userSignature');
}

const serializedTxHex = unsignedSweepPrebuildTx.txHex ?? unsignedSweepPrebuildTx.tx;
if (!serializedTxHex) {
throw new Error('missing txHex');
}

const replayProtectionOptions =
unsignedSweepPrebuildTx.replayProtectionOptions ??
({
chain: this.getChainId(),
hardfork: unsignedSweepPrebuildTx.eip1559 ? 'london' : optionalDeps.EthCommon.Hardfork.Petersburg,
} as ReplayProtectionOptions);

const ethCommon = AbstractEthLikeNewCoins.getEthLikeCommon(
unsignedSweepPrebuildTx.eip1559,
replayProtectionOptions
);
const txBuffer = optionalDeps.ethUtil.toBuffer(serializedTxHex);

let unsignedTx: EthLikeTxLib.FeeMarketEIP1559Transaction | EthLikeTxLib.Transaction;
if (unsignedSweepPrebuildTx.eip1559) {
unsignedTx = FeeMarketEIP1559Transaction.fromSerializedTx(txBuffer, { common: ethCommon });
} else {
unsignedTx = LegacyTransaction.fromSerializedTx(txBuffer, { common: ethCommon });
}

if (!unsignedTx.to) {
throw new Error('unsigned recovery tx is missing to address');
}

const dataHex = optionalDeps.ethUtil.bufferToHex(unsignedTx.data);
const transferData = decodeTransferData(dataHex);

const newDataHex = transferData.tokenContractAddress
? sendMultiSigTokenData(
transferData.to,
transferData.amount,
transferData.tokenContractAddress,
transferData.expireTime,
transferData.sequenceId,
userSignature
)
: sendMultiSigData(
transferData.to,
transferData.amount,
transferData.data ?? '0x',
transferData.expireTime,
transferData.sequenceId,
userSignature
);

const buildParams: BuildTransactionParams = {
to: unsignedTx.to.toString(),
nonce: unsignedTx.nonce.toNumber(),
value: 0,
data: Buffer.from(optionalDeps.ethUtil.stripHexPrefix(newDataHex), 'hex'),
gasLimit: unsignedTx.gasLimit.toNumber(),
eip1559: unsignedSweepPrebuildTx.eip1559,
replayProtectionOptions,
};

if (!unsignedSweepPrebuildTx.eip1559) {
const legacyTx = unsignedTx as EthLikeTxLib.Transaction;
buildParams.gasPrice = legacyTx.gasPrice.toNumber();
}

const rebuiltTx = AbstractEthLikeNewCoins.buildTransaction(buildParams);

return {
txHash: optionalDeps.ethUtil.bufferToHex(Buffer.from(rebuiltTx.getMessageToSign(true))),
txHex: addHexPrefix(rebuiltTx.serialize().toString('hex')),
};
}

/**
* Attaches the backup key signature to an unsigned recovery tx and returns broadcast-ready tx hex.
* Used by external signing flows (e.g. AWM) as the final step of multisig recovery.
*
* @param txHex - unsigned tx hex from buildRecoveryTxForBackupSigning
* @param backupSignature - ECDSA signature of txHash from the backup key (r+s+v, v is 27/28 or 0/1)
* @returns fully signed, broadcast-ready transaction hex
*/
async finalizeRecoveryTx(txHex: string, backupSignature: string): Promise<string> {
if (!txHex) {
throw new Error('missing txHex');
}
if (!backupSignature) {
throw new Error('missing backupSignature');
}

const txBuffer = optionalDeps.ethUtil.toBuffer(txHex);
const isEip1559 = txBuffer.length > 0 && txBuffer[0] === 0x02;
const ethCommon = AbstractEthLikeNewCoins.getCustomChainCommon(this.getChainId());
ethCommon.setHardfork(isEip1559 ? 'london' : optionalDeps.EthCommon.Hardfork.Petersburg);

let unsignedTx: EthLikeTxLib.FeeMarketEIP1559Transaction | EthLikeTxLib.Transaction;
if (isEip1559) {
unsignedTx = FeeMarketEIP1559Transaction.fromSerializedTx(txBuffer, { common: ethCommon });
} else {
unsignedTx = LegacyTransaction.fromSerializedTx(txBuffer, { common: ethCommon });
}

const signedTx = this.getSignedTxFromSignature(ethCommon, unsignedTx, this.parseEcdsaSignature(backupSignature));

return addHexPrefix(signedTx.serialize().toString('hex'));
}

/**
* Parse a 65-byte ECDSA signature (r+s+v) into the format used by getSignedTxFromSignature.
*/
private parseEcdsaSignature(signature: string): ECDSAMethodTypes.Signature {
const sigBuffer = Buffer.from(stripHexPrefix(signature), 'hex');
if (sigBuffer.length !== 65) {
throw new Error(`Invalid signature length: expected 65 bytes, got ${sigBuffer.length}`);
}
const r = addHexPrefix(sigBuffer.subarray(0, 32).toString('hex'));
const s = addHexPrefix(sigBuffer.subarray(32, 64).toString('hex'));
const v = sigBuffer[64];
const recid = v > 1 ? v - 27 : v;
return { r, s, recid } as ECDSAMethodTypes.Signature;
}

/**
* Extract recipients from transaction hex
* @param txHex - The transaction hex string
Expand Down
63 changes: 62 additions & 1 deletion modules/abstract-eth/test/unit/coin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { bip32 } from '@bitgo/secp256k1';
import * as secp256k1 from 'secp256k1';
import { FullySignedTransaction, TransactionType, Wallet } from '@bitgo/sdk-core';
import { FullySignedTransaction, TransactionType, Util, Wallet } from '@bitgo/sdk-core';
import nock from 'nock';
import * as should from 'should';
import {
Expand Down Expand Up @@ -643,5 +643,66 @@ export function runRecoveryTransactionTests(
rebuiltTx.signature.length.should.equal(2);
rebuiltTx.outputs.length.should.equal(1);
});

it('should support external signing recovery via buildRecoveryTxForBackupSigning and finalizeRecoveryTx', async () => {
const walletContractAddress = TestBitGo.V2.TEST_ETH_WALLET_FIRST_ADDRESS as string;
const backupKeyAddress = '0x4f2c4830cc37f2785c646f89ded8a919219fa0e9';
nock(baseUrl)
.get('/api')
.twice()
.query(mockData.getTxListRequest(backupKeyAddress))
.reply(200, mockData.getTxListResponse);
nock(baseUrl)
.get('/api')
.query(mockData.getBalanceRequest(walletContractAddress))
.reply(200, mockData.getBalanceResponse);
nock(baseUrl)
.get('/api')
.query(mockData.getBalanceRequest(backupKeyAddress))
.reply(200, mockData.getBalanceResponse);
nock(baseUrl).get('/api').query(mockData.getContractCallRequest).reply(200, mockData.getContractCallResponse);
const basecoin: any = bitgo.coin(coin);
const unsignedSweep = (await basecoin.recover({
userKey: userXpub,
backupKey: backupXpub,
walletContractAddress: walletContractAddress,
recoveryDestination: TestBitGo.V2.TEST_ERC20_TOKEN_RECIPIENT as string,
eip1559: { maxFeePerGas: 20000000000, maxPriorityFeePerGas: 10000000000 },
replayProtectionOptions: { chain: 80001, hardfork: 'london' },
gasLimit: 500000,
})) as OfflineVaultTxInfo;

const unsignedSweepWithMeta = unsignedSweep as OfflineVaultTxInfo & {
expireTime: number;
contractSequenceId: number;
};

const operationHash = basecoin.getOperationSha3ForExecuteAndConfirm(
unsignedSweepWithMeta.recipients,
unsignedSweepWithMeta.expireTime,
unsignedSweepWithMeta.contractSequenceId ?? unsignedSweepWithMeta.nextContractSequenceId
);
const userSignature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userXprv));

const { txHash, txHex } = await basecoin.buildRecoveryTxForBackupSigning(unsignedSweep, userSignature);
should.exist(txHash);
should.exist(txHex);

const backupPrivateKey = bip32.fromBase58(backupXprv).privateKey!;
const hashBuffer = Buffer.from(optionalDeps.ethUtil.stripHexPrefix(txHash), 'hex');
const sigParts = optionalDeps.ethUtil.ecsign(hashBuffer, backupPrivateKey);
const r = optionalDeps.ethUtil.setLengthLeft(sigParts.r, 32).toString('hex');
const s = optionalDeps.ethUtil.setLengthLeft(sigParts.s, 32).toString('hex');
const v = optionalDeps.ethUtil.stripHexPrefix(optionalDeps.ethUtil.intToHex(sigParts.v));
const backupSignature = optionalDeps.ethUtil.addHexPrefix(r.concat(s, v));

const signedTxHex = await basecoin.finalizeRecoveryTx(txHex, backupSignature);
should.exist(signedTxHex);

txBuilder.from(signedTxHex);
const rebuiltTx = await txBuilder.build();
rebuiltTx.signature.length.should.equal(2);
rebuiltTx.outputs.length.should.equal(1);
});
});
}
67 changes: 65 additions & 2 deletions modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'assert';
import { BitGoAPI } from '@bitgo/sdk-api';
import { common, FullySignedTransaction, HalfSignedTransaction, TransactionType } from '@bitgo/sdk-core';
import { OfflineVaultTxInfo, TransferBuilder } from '@bitgo/abstract-eth';
import { common, FullySignedTransaction, HalfSignedTransaction, TransactionType, Util } from '@bitgo/sdk-core';
import { OfflineVaultTxInfo, optionalDeps, TransferBuilder } from '@bitgo/abstract-eth';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { bip32 } from '@bitgo/secp256k1';
import nock from 'nock';
Expand Down Expand Up @@ -357,6 +357,69 @@ describe('EthLikeCoin', function () {
assert(transaction.contractSequenceId);
assert.strictEqual(transaction.gasLimit, '500000');
});

it('should support external signing recovery via buildRecoveryTxForBackupSigning and finalizeRecoveryTx', async function () {
const walletContractAddress = TestBitGo.V2.TEST_ETH_WALLET_FIRST_ADDRESS as string;
const backupKeyAddress = '0x4f2c4830cc37f2785c646f89ded8a919219fa0e9';
const userXprv =
'xprv9s21ZrQH143K2AP925b8zB8evEQ4JvYCsqZQKPcp4gopaN81TzUxEW8bPtVyDgjmddGhRRETn8xi1cVAB9bf1Bx9kGRRFgTZXxJayZLnag1';
const backupXprv =
'xprv9s21ZrQH143K35SXywHZHvHgeETE5yaumd9bGbHQRBx3Q4JQew5JFGBvzqiZjCUkBdBUZnfuMDTGURRayN1hFSWxEJQsCEAMm1D3pk1h7Jj';

nock(baseUrl)
.get('/api')
.twice()
.query(mockData.getTxListRequest(backupKeyAddress))
.reply(200, mockData.getTxListResponse);
nock(baseUrl)
.get('/api')
.query(mockData.getBalanceRequest(walletContractAddress))
.reply(200, mockData.getBalanceResponse);
nock(baseUrl)
.get('/api')
.query(mockData.getBalanceRequest(backupKeyAddress))
.reply(200, mockData.getBalanceResponse);
nock(baseUrl).get('/api').query(mockData.getContractCallRequest).reply(200, mockData.getContractCallResponse);

const baseCoin = bitgo.coin('tbaseeth') as TethLikeCoin;
const unsignedSweep = (await baseCoin.recover({
userKey: userXpub,
backupKey: backupXpub,
walletContractAddress: walletContractAddress,
recoveryDestination: TestBitGo.V2.TEST_ERC20_TOKEN_RECIPIENT as string,
eip1559: { maxFeePerGas: 20000000000, maxPriorityFeePerGas: 10000000000 },
gasLimit: 500000,
common: baseChainCommon,
})) as OfflineVaultTxInfo & { expireTime: number; contractSequenceId: number };

const operationHash = baseCoin.getOperationSha3ForExecuteAndConfirm(
unsignedSweep.recipients,
unsignedSweep.expireTime,
unsignedSweep.contractSequenceId ?? unsignedSweep.nextContractSequenceId
);
const userSignature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userXprv));

const { txHash, txHex } = await baseCoin.buildRecoveryTxForBackupSigning(unsignedSweep, userSignature);
assert(txHash);
assert(txHex);

const backupPrivateKey = bip32.fromBase58(backupXprv).privateKey!;
const hashBuffer = Buffer.from(optionalDeps.ethUtil.stripHexPrefix(txHash), 'hex');
const sigParts = optionalDeps.ethUtil.ecsign(hashBuffer, backupPrivateKey);
const r = optionalDeps.ethUtil.setLengthLeft(sigParts.r, 32).toString('hex');
const s = optionalDeps.ethUtil.setLengthLeft(sigParts.s, 32).toString('hex');
const v = optionalDeps.ethUtil.stripHexPrefix(optionalDeps.ethUtil.intToHex(sigParts.v));
const backupSignature = optionalDeps.ethUtil.addHexPrefix(r.concat(s, v));

const signedTxHex = await baseCoin.finalizeRecoveryTx(txHex, backupSignature);
assert(signedTxHex);

const txBuilder = getBuilder('tbaseeth', baseChainCommon) as EthLikeTransactionBuilder;
txBuilder.from(signedTxHex);
const rebuiltTx = await txBuilder.build();
assert.strictEqual(rebuiltTx.signature.length, 2);
assert.strictEqual(rebuiltTx.outputs.length, 1);
});
});

describe('Evm Based Cross Chain Recovery transaction:', function () {
Expand Down
Loading