diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index ea3126a6fe..cfd9e826eb 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -87,6 +87,8 @@ import { getRawDecoded, getToken, KeyPair as KeyPairLib, + sendMultiSigData, + sendMultiSigTokenData, TransactionBuilder, TransferBuilder, } from './lib'; @@ -218,6 +220,8 @@ export interface OfflineVaultTxInfo { feesUsed?: FeesUsed; isEvmBasedCrossChainRecovery?: boolean; walletVersion?: number; + expireTime?: number; + operationHash?: string; } interface UnformattedTxInfo { @@ -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 { + 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 diff --git a/modules/abstract-eth/test/unit/coin.ts b/modules/abstract-eth/test/unit/coin.ts index 06b9010246..1ea8b5a48c 100644 --- a/modules/abstract-eth/test/unit/coin.ts +++ b/modules/abstract-eth/test/unit/coin.ts @@ -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 { @@ -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); + }); }); } diff --git a/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts b/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts index d55a8205fc..ceaa1f7338 100644 --- a/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts +++ b/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts @@ -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'; @@ -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 () {