From aaaf29b12c228fe888650cd6671949f7ab332fdb Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Thu, 23 Oct 2025 12:14:44 +0530 Subject: [PATCH] feat: add logic for vechain token recoveries Ticket: COIN-6042 --- modules/bitgo/test/v2/lib/recovery-nocks.ts | 109 ++++++++++++ modules/bitgo/test/v2/unit/recovery.ts | 38 ++++ modules/sdk-coin-vet/src/lib/constants.ts | 7 + modules/sdk-coin-vet/src/lib/types.ts | 1 + modules/sdk-coin-vet/src/vet.ts | 18 +- modules/sdk-coin-vet/src/vetToken.ts | 184 +++++++++++++++++++- 6 files changed, 340 insertions(+), 17 deletions(-) diff --git a/modules/bitgo/test/v2/lib/recovery-nocks.ts b/modules/bitgo/test/v2/lib/recovery-nocks.ts index ef74b91b18..ed9e872a56 100644 --- a/modules/bitgo/test/v2/lib/recovery-nocks.ts +++ b/modules/bitgo/test/v2/lib/recovery-nocks.ts @@ -506,6 +506,115 @@ module.exports.nockVetRecovery = function (bitgo, baseAddress) { }); }; +module.exports.nockVetTokenRecovery = function (bitgo, baseAddress) { + // nock for account balance + const url = Environments[bitgo.getEnv()].vetNodeUrl; + nock(url) + .post('/accounts/*', { + clauses: [ + { + to: '0x0000000000000000000000000000456E65726779', + value: '0x0', + data: `0x70a08231000000000000000000000000${baseAddress.slice(2)}`, + }, + ], + }) + .reply(200, [ + { + data: '0x000000000000000000000000000000000000000000000000416003863bd917f8', + events: [], + transfers: [], + gasUsed: 870, + reverted: false, + vmError: '', + }, + ]); + + nock(url).get('/blocks/best').reply(200, { + number: 23107826, + id: '0x016098f2a6779c3ad2bb52ef0a3f57c770af55a77bfa1b2837266f752118ad8d', + size: 368, + parentID: '0x016098f1acffb0125ffeca9b3e2491d31574d14b55a15e912e45e8081e063e0e', + timestamp: 1761116630, + gasLimit: 40000000, + beneficiary: '0xae99cb89767a09d53e589a40cb4016974aba4b94', + gasUsed: 0, + totalScore: 218523577, + txsRoot: '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + txsFeatures: 1, + stateRoot: '0x7a5e7b3b8b89958e7fdd5e14acbc79dbc419672e84d02376a43b3beebe555e33', + receiptsRoot: '0x45b0cfc220ceec5b7c1c62c4d4193d38e4eba48e8815729ce75f9c0ab0e4c1c0', + com: true, + signer: '0xae99cb89767a09d53e589a40cb4016974aba4b94', + isTrunk: true, + isFinalized: false, + baseFeePerGas: '0x9184e72a000', + transactions: [], + }); + + nock(url) + .post('/accounts/*', { + clauses: [ + { + to: '0x0000000000000000000000000000456E65726779', + value: '0x0', + data: '0xa9059cbb000000000000000000000000ac05da78464520aa7c9d4c19bd7a440b111b305400000000000000000000000000000000000000000000000036a9d31575bcee8e', + }, + ], + caller: `${baseAddress}`, + }) + .reply(200, [ + { + data: '0x0000000000000000000000000000000000000000000000000000000000000001', + events: [ + { + address: '0x0000000000000000000000000000456e65726779', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x000000000000000000000000880ff4718587d678e78fc7803b3634bd12ecf019', + '0x000000000000000000000000ac05da78464520aa7c9d4c19bd7a440b111b3054', + ], + data: '0x00000000000000000000000000000000000000000000000036a9d31575bcee8e', + }, + ], + transfers: [], + gasUsed: 13326, + reverted: false, + vmError: '', + }, + ]); + // nock for vtho balance for gas + nock(url) + .post('/accounts/*', { + clauses: [ + { + to: '0x0000000000000000000000000000456E65726779', + value: '0x0', + data: `0x70a08231000000000000000000000000${baseAddress.slice(2)}`, + }, + ], + }) + .reply(200, [ + { + data: '0x000000000000000000000000000000000000000000000000416003863bd917f8', + events: [], + transfers: [], + gasUsed: 870, + reverted: false, + vmError: '', + }, + ]); + + nock(url) + .post('/transactions', { + raw: /^0x[0-9a-f]+$/i, + }) + .reply(200, { + id: '0x' + 'a'.repeat(64), // A fake transaction ID + reverted: false, + }); +}; + module.exports.nockEtherscanRateLimitError = function () { const response = { status: '0', diff --git a/modules/bitgo/test/v2/unit/recovery.ts b/modules/bitgo/test/v2/unit/recovery.ts index 3329ce1e46..29fd76534d 100644 --- a/modules/bitgo/test/v2/unit/recovery.ts +++ b/modules/bitgo/test/v2/unit/recovery.ts @@ -1500,5 +1500,43 @@ describe('Recovery:', function () { recovery.should.have.property('coin'); recovery.coin.should.equal('tvet'); }); + + it('should construct a token(vtho) recovery tx with MPCv2 TSS', async function () { + const basecoin = bitgo.coin('tvet:vtho'); + const baseAddress = ethLikeDKLSKeycard.senderAddress; + recoveryNocks.nockVetTokenRecovery(bitgo, baseAddress); + recoveryParams = { + userKey: ethLikeDKLSKeycard.userKey, + backupKey: ethLikeDKLSKeycard.backupKey, + walletContractAddress: baseAddress, + recoveryDestination: ethLikeDKLSKeycard.destinationAddress, + walletPassphrase: ethLikeDKLSKeycard.walletPassphrase, + tokenContractAddress: '0x0000000000000000000000000000456E65726779', + }; + + const recovery = await basecoin.recover(recoveryParams); + + should.exist(recovery); + recovery.should.have.property('id'); + recovery.should.have.property('tx'); + }); + + it('should construct an unsigned sweep token tx(vtho) with TSS', async function () { + recoveryNocks.nockVetTokenRecovery(bitgo, '0xad848d2c97a08b2cd5e7f28f76ecd45dd0f82e0e'); + const basecoin = bitgo.coin('tvet:vtho'); + + const unsignedSweepRecoveryParams = { + bitgoKey: + '03f54983c529802697d9a2320ded23eb7f15118fcba01156356c2264f04d32b4caa77fcf8cf3f73547078e984f28787c4c1e694586214b609e45b6de9cc32ad6e5', + recoveryDestination: ethLikeDKLSKeycard.destinationAddress, + tokenContractAddress: '0x0000000000000000000000000000456E65726779', + }; + + const recovery = await basecoin.recover(unsignedSweepRecoveryParams); + should.exist(recovery); + recovery.should.have.property('txHex'); + recovery.should.have.property('coin'); + recovery.coin.should.equal('tvet:vtho'); + }); }); }); diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index c9349a1250..9e88838078 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -21,3 +21,10 @@ export const EXPIRATION = 400; export const GAS_PRICE_COEF = '128'; export const GAS_UNIT_PRICE = '10000000000000'; // vechain has fixed gas unit price of 10^13 wei export const COEF_DIVISOR = '255'; + +export const feeEstimateData = { + gas: AVG_GAS_UNITS, + gasUnitPrice: GAS_UNIT_PRICE, + gasPriceCoef: GAS_PRICE_COEF, + coefDivisor: COEF_DIVISOR, +}; diff --git a/modules/sdk-coin-vet/src/lib/types.ts b/modules/sdk-coin-vet/src/lib/types.ts index 7f87275b1b..0795d8043c 100644 --- a/modules/sdk-coin-vet/src/lib/types.ts +++ b/modules/sdk-coin-vet/src/lib/types.ts @@ -23,6 +23,7 @@ export type RecoverOptions = { recoveryDestination: string; isUnsignedSweep?: boolean; // specify if this is an unsigned recovery bitgoKey?: string; + tokenContractAddress?: string; }; export interface RecoveryTransaction { diff --git a/modules/sdk-coin-vet/src/vet.ts b/modules/sdk-coin-vet/src/vet.ts index 7a96980a37..c0b3b32330 100644 --- a/modules/sdk-coin-vet/src/vet.ts +++ b/modules/sdk-coin-vet/src/vet.ts @@ -43,7 +43,7 @@ import { VetParseTransactionOptions, } from './lib/types'; import { VetTransactionExplanation } from './lib/iface'; -import { AVG_GAS_UNITS, COEF_DIVISOR, EXPIRATION, GAS_PRICE_COEF, GAS_UNIT_PRICE } from './lib/constants'; +import { AVG_GAS_UNITS, EXPIRATION, GAS_PRICE_COEF, feeEstimateData } from './lib/constants'; interface FeeEstimateData { gas: string; @@ -52,13 +52,6 @@ interface FeeEstimateData { coefDivisor: string; } -const feeEstimateData: FeeEstimateData = { - gas: AVG_GAS_UNITS, - gasUnitPrice: GAS_UNIT_PRICE, - gasPriceCoef: GAS_PRICE_COEF, - coefDivisor: COEF_DIVISOR, -}; - /** * Full Name: Vechain * Docs: https://docs.vechain.org/ @@ -419,11 +412,6 @@ export class Vet extends BaseCoin { const signedTx = await txBuilder.build(); - // broadcast this transaction - await this.broadcastTransaction({ - serializedSignedTransaction: signedTx.toBroadcastFormat(), - }); - return { id: signedTx.id, tx: signedTx.toBroadcastFormat(), @@ -447,7 +435,7 @@ export class Vet extends BaseCoin { * @param {BigNumber} estimatedGasLimit - The estimated gas limit for the transaction. * @returns {BigNumber} The calculated transaction fee. */ - private calculateFee(feeEstimateData: FeeEstimateData, estimatedGasLimit: BigNumber): BigNumber { + protected calculateFee(feeEstimateData: FeeEstimateData, estimatedGasLimit: BigNumber): BigNumber { const gasLimit = estimatedGasLimit; const adjustmentFactor = new BigNumber(1).plus( new BigNumber(feeEstimateData.gasPriceCoef) @@ -630,7 +618,7 @@ export class Vet extends BaseCoin { * @returns {Promise} A promise that resolves to the built recovery transaction. * @throws {Error} If there's no VET balance to recover or if there's an error building the transaction. */ - private async buildRecoveryTransaction(buildParams: { + protected async buildRecoveryTransaction(buildParams: { baseAddress: string; params: RecoverOptions; }): Promise { diff --git a/modules/sdk-coin-vet/src/vetToken.ts b/modules/sdk-coin-vet/src/vetToken.ts index 2ff1ba2588..2663a9e8ec 100644 --- a/modules/sdk-coin-vet/src/vetToken.ts +++ b/modules/sdk-coin-vet/src/vetToken.ts @@ -1,6 +1,13 @@ -import { Vet } from './vet'; -import { BitGoBase, CoinConstructor, NamedCoinConstructor } from '@bitgo/sdk-core'; +import assert from 'assert'; +import BigNumber from 'bignumber.js'; +import { BitGoBase, CoinConstructor, Ecdsa, ECDSAUtils, NamedCoinConstructor } from '@bitgo/sdk-core'; import { VetTokenConfig, coins, tokens } from '@bitgo/statics'; +import * as mpc from '@bitgo/sdk-lib-mpc'; +import { KeyPair as EthKeyPair } from '@bitgo/abstract-eth'; +import { RecoverOptions, RecoveryTransaction, UnsignedSweepRecoveryTransaction } from './lib/types'; +import { TokenTransaction, Transaction } from './lib'; +import { AVG_GAS_UNITS, EXPIRATION, feeEstimateData, GAS_PRICE_COEF } from './lib/constants'; +import { Vet } from './vet'; export class VetToken extends Vet { public readonly tokenConfig: VetTokenConfig; @@ -59,4 +66,177 @@ export class VetToken extends Vet { getBaseFactor(): number { return Math.pow(10, this.tokenConfig.decimalPlaces); } + + async recover(params: RecoverOptions): Promise { + try { + if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) { + throw new Error('invalid recoveryDestination'); + } + if (!params.tokenContractAddress || !this.isValidAddress(params.tokenContractAddress)) { + throw new Error('invalid tokenContractAddress'); + } + + const isUnsignedSweep = !params.userKey && !params.backupKey && !params.walletPassphrase; + + let publicKey: string | undefined; + let userKeyShare, backupKeyShare, commonKeyChain; + const MPC = new Ecdsa(); + + if (isUnsignedSweep) { + const bitgoKey = params.bitgoKey; + if (!bitgoKey) { + throw new Error('missing bitgoKey'); + } + + const hdTree = new mpc.Secp256k1Bip32HdTree(); + const derivationPath = 'm/0'; + const derivedPub = hdTree.publicDerive( + { + pk: mpc.bigIntFromBufferBE(Buffer.from(bitgoKey.slice(0, 66), 'hex')), + chaincode: mpc.bigIntFromBufferBE(Buffer.from(bitgoKey.slice(66), 'hex')), + }, + derivationPath + ); + + publicKey = mpc.bigIntToBufferBE(derivedPub.pk).toString('hex'); + } else { + if (!params.userKey) { + throw new Error('missing userKey'); + } + + if (!params.backupKey) { + throw new Error('missing backupKey'); + } + + if (!params.walletPassphrase) { + throw new Error('missing wallet passphrase'); + } + + const userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + ({ userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares( + userKey, + backupKey, + params.walletPassphrase + )); + publicKey = MPC.deriveUnhardened(commonKeyChain, 'm/0').slice(0, 66); + } + + if (!publicKey) { + throw new Error('failed to derive public key'); + } + + const backupKeyPair = new EthKeyPair({ pub: publicKey }); + const baseAddress = backupKeyPair.getAddress(); + + const tx = await this.buildRecoveryTransaction({ + baseAddress, + params, + }); + + const signableHex = await tx.signablePayload; + const serializedTxHex = await tx.toBroadcastFormat(); + + if (isUnsignedSweep) { + return { + txHex: serializedTxHex, + coin: this.getChain(), + }; + } + + const signableMessage = this.getHashFunction().update(signableHex).digest(); + + const signatureObj = await ECDSAUtils.signRecoveryMpcV2( + signableMessage, + userKeyShare, + backupKeyShare, + commonKeyChain + ); + const signature = Buffer.from(signatureObj.r + signatureObj.s + (signatureObj.recid === 0 ? '00' : '01'), 'hex'); + const tokenTransaction = new TokenTransaction(coins.get(this.getChain())); + const txBuilder = this.getTxBuilderFactory().getTokenTransactionBuilder(tokenTransaction); + await txBuilder.from(serializedTxHex); + txBuilder.isRecovery(true); + await txBuilder.addSenderSignature(signature); + + const signedTx = await txBuilder.build(); + + return { + id: signedTx.id, + tx: signedTx.toBroadcastFormat(), + }; + } catch (error) { + throw new Error(`Error during Vechain token recovery: ${error.message || error}`); + } + } + + protected async buildRecoveryTransaction(buildParams: { + baseAddress: string; + params: RecoverOptions; + }): Promise { + const { baseAddress, params } = buildParams; + const tokenContractAddress = params.tokenContractAddress; + assert(tokenContractAddress, 'tokenContractAddress is required for token recovery'); + + const balance = await this.getBalance(baseAddress, tokenContractAddress); + //replace with get balance function + + if (balance.isLessThanOrEqualTo(0)) { + throw new Error( + `no token balance to recover for address ${baseAddress} contract address ${tokenContractAddress}` + ); + } + + // create the recipients here so that we can build the clauses for gas estimation + const roughFeeEstimate = this.calculateFee(feeEstimateData, new BigNumber(51390)); + let recipients = [ + { + address: params.recoveryDestination, + amount: balance.minus(roughFeeEstimate).toString(), + }, + ]; + + const blockRef = await this.getBlockRef(); + + const tokenTransaction = new TokenTransaction(coins.get(this.getChain())); + const txBuilder = this.getTxBuilderFactory().getTokenTransactionBuilder(tokenTransaction); + + txBuilder.tokenAddress(tokenContractAddress); + txBuilder.chainTag(this.bitgo.getEnv() === 'prod' ? 0x4a : 0x27); + txBuilder.recipients(recipients); + txBuilder.sender(baseAddress); + txBuilder.addFeePayerAddress(baseAddress); + txBuilder.gas(Number(AVG_GAS_UNITS)); + txBuilder.blockRef(blockRef); + txBuilder.expiration(EXPIRATION); + txBuilder.gasPriceCoef(Number(GAS_PRICE_COEF)); + txBuilder.nonce(this.getRandomNonce()); + txBuilder.isRecovery(true); + + let tx = (await txBuilder.build()) as Transaction; + + const clauses = tx.clauses; + + const actualGasUnits = await this.estimateGas(clauses, baseAddress); + + await this.ensureVthoBalanceForFee(baseAddress, actualGasUnits); + + const requiredFee = this.calculateFee(feeEstimateData, actualGasUnits); + + // create the final recipients with the fee deducted + recipients = [ + { + address: params.recoveryDestination, + amount: balance.minus(requiredFee).toString(), + }, + ]; + + txBuilder.recipients(recipients); + txBuilder.gas(actualGasUnits.toNumber()); + + tx = (await txBuilder.build()) as Transaction; + + return tx; + } }