From 140f0ce419a0a1093a463a0f955d094ee7c16334 Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Thu, 9 Jan 2025 15:01:48 +0530 Subject: [PATCH] fix(sdk-coin-rune): add native transaction fees for wallet recovery Ticket: COIN-2417 --- modules/sdk-coin-rune/src/lib/constants.ts | 4 +- modules/sdk-coin-rune/src/rune.ts | 128 ++++++++++++++++++++- modules/sdk-coin-rune/test/unit/rune.ts | 5 +- 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/modules/sdk-coin-rune/src/lib/constants.ts b/modules/sdk-coin-rune/src/lib/constants.ts index 2d161b65ac..d3ab80c953 100644 --- a/modules/sdk-coin-rune/src/lib/constants.ts +++ b/modules/sdk-coin-rune/src/lib/constants.ts @@ -9,4 +9,6 @@ export const testnetValidatorAddressRegex = /^(sthor)1(['qpzry9x8gf2tvdw0s3jn54k export const TESTNET_ADDRESS_PREFIX = 'sthor'; export const GAS_LIMIT = 200000; -export const GAS_AMOUNT = '150000'; +export const GAS_AMOUNT = '0'; // Gas amount should be zero for RUNE transactions, as fees (0.02 RUNE) is cut from sender balance directly in the transaction +export const RUNE_FEES = '2000000'; // https://dev.thorchain.org/concepts/fees.html#thorchain-native-rune +export const ROOT_PATH = 'm/0'; diff --git a/modules/sdk-coin-rune/src/rune.ts b/modules/sdk-coin-rune/src/rune.ts index 6614aeb22b..9600076744 100644 --- a/modules/sdk-coin-rune/src/rune.ts +++ b/modules/sdk-coin-rune/src/rune.ts @@ -1,12 +1,31 @@ -import { CosmosCoin, CosmosKeyPair, GasAmountDetails } from '@bitgo/abstract-cosmos'; -import { BaseCoin, BitGoBase, Environments, TransactionType, VerifyTransactionOptions } from '@bitgo/sdk-core'; +import { + CosmosCoin, + CosmosKeyPair, + CosmosLikeCoinRecoveryOutput, + CosmosTransaction, + FeeData, + GasAmountDetails, + RecoveryOptions, + SendMessage, +} from '@bitgo/abstract-cosmos'; +import { + BaseCoin, + BitGoBase, + Ecdsa, + ECDSAUtils, + Environments, + TransactionType, + VerifyTransactionOptions, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, BaseUnit, coins } from '@bitgo/statics'; import { KeyPair, TransactionBuilderFactory } from './lib'; -import { GAS_AMOUNT, GAS_LIMIT } from './lib/constants'; +import { GAS_AMOUNT, GAS_LIMIT, RUNE_FEES, ROOT_PATH } from './lib/constants'; import { RuneUtils } from './lib/utils'; import { BigNumber } from 'bignumber.js'; const bech32 = require('bech32-buffer'); import * as _ from 'lodash'; +import { Coin } from '@cosmjs/stargate'; +import { createHash } from 'crypto'; export class Rune extends CosmosCoin { protected readonly _utils: RuneUtils; @@ -106,4 +125,107 @@ export class Rune extends CosmosCoin { } return true; } + + getNativeRuneTxnFees(): string { + return RUNE_FEES; + } + + /** + * This function is overridden from CosmosCoin class' recover function due to the difference in fees handling in thorchain + * @param {RecoveryOptions} params parameters needed to construct and + * (maybe) sign the transaction + * + * @returns {CosmosLikeCoinRecoveryOutput} the serialized transaction hex string and index + * of the address being swept + */ + async recover(params: RecoveryOptions): Promise { + // Step 1: Check if params contains the required parameters + + if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) { + throw new Error('invalid recoveryDestination'); + } + + 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'); + } + + // Step 2: Fetch the bitgo key from params + const userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares( + userKey, + backupKey, + params.walletPassphrase + ); // baseAddress is not extracted + // Step 3: Instantiate the ECDSA signer and fetch the address details + const MPC = new Ecdsa(); + const chainId = await this.getChainId(); + const publicKey = MPC.deriveUnhardened(commonKeyChain, ROOT_PATH).slice(0, 66); + const senderAddress = this.getAddressFromPublicKey(publicKey); + + // Step 4: Fetch account details such as accountNo, balance and check for sufficient funds once gasAmount has been deducted + const [accountNumber, sequenceNo] = await this.getAccountDetails(senderAddress); + const balance = new BigNumber(await this.getAccountBalance(senderAddress)); + const gasBudget: FeeData = { + amount: [{ denom: this.getDenomination(), amount: this.getGasAmountDetails().gasAmount }], + gasLimit: this.getGasAmountDetails().gasLimit, + }; + const actualBalance = balance.minus(this.getNativeRuneTxnFees()); + + if (actualBalance.isLessThanOrEqualTo(0)) { + throw new Error('Did not have enough funds to recover'); + } + + // Step 5: Once sufficient funds are present, construct the recover tx messsage + const amount: Coin[] = [ + { + denom: this.getDenomination(), + amount: actualBalance.toFixed(), + }, + ]; + const sendMessage: SendMessage[] = [ + { + fromAddress: senderAddress, + toAddress: params.recoveryDestination, + amount: amount, + }, + ]; + + // Step 6: Build the unsigned tx using the constructed message + const txnBuilder = this.getBuilder().getTransferBuilder(); + txnBuilder + .messages(sendMessage) + .gasBudget(gasBudget) + .publicKey(publicKey) + .sequence(Number(sequenceNo)) + .accountNumber(Number(accountNumber)) + .chainId(chainId); + const unsignedTransaction = (await txnBuilder.build()) as CosmosTransaction; + let serializedTx = unsignedTransaction.toBroadcastFormat(); + const signableHex = unsignedTransaction.signablePayload.toString('hex'); + + // Step 7: Sign the tx + const message = unsignedTransaction.signablePayload; + const messageHash = createHash('sha256').update(message).digest(); + + const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain); + + const signableBuffer = Buffer.from(signableHex, 'hex'); + MPC.verify(signableBuffer, signature, this.getHashFunction()); + const cosmosKeyPair = this.getKeyPair(publicKey); + txnBuilder.addSignature({ pub: cosmosKeyPair.getKeys().pub }, Buffer.from(signature.r + signature.s, 'hex')); + const signedTransaction = await txnBuilder.build(); + serializedTx = signedTransaction.toBroadcastFormat(); + + return { serializedTx: serializedTx }; + } } diff --git a/modules/sdk-coin-rune/test/unit/rune.ts b/modules/sdk-coin-rune/test/unit/rune.ts index c9c44bd136..8c6a7a89b6 100644 --- a/modules/sdk-coin-rune/test/unit/rune.ts +++ b/modules/sdk-coin-rune/test/unit/rune.ts @@ -11,7 +11,6 @@ const bech32 = require('bech32-buffer'); import should = require('should'); import { beforeEach } from 'mocha'; import { CosmosTransaction, SendMessage } from '@bitgo/abstract-cosmos'; -import { GAS_AMOUNT } from '../../src/lib/constants'; describe('Rune', function () { let bitgo: TestBitGoAPI; @@ -272,7 +271,7 @@ describe('Rune', function () { describe('Recover transaction: success path', () => { const sandBox = sinon.createSandbox(); const coin = coins.get('tthorchain:rune'); - const testBalance = '1500000'; + const testBalance = '15000000'; const testAccountNumber = '123'; const testSequenceNumber = '0'; const testChainId = 'thorchain-stagenet-2'; @@ -312,7 +311,7 @@ describe('Rune', function () { const truneTxnJson = truneTxn.toJson(); const sendMessage = truneTxnJson.sendMessages[0].value as SendMessage; const balance = new BigNumber(testBalance); - const actualBalance = balance.minus(new BigNumber(GAS_AMOUNT)); + const actualBalance = balance.minus('2000000'); // native rune transaction fees should.equal(sendMessage.amount[0].amount, actualBalance.toFixed()); }); });