From 1fa115f833b727d496b44b146020e58374b65b50 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Fri, 31 Jan 2025 18:26:37 +0530 Subject: [PATCH] refactor(sdk-coin-hbar): add recovery for hbar tokens TICKET: WIN-4342 --- modules/sdk-coin-hbar/src/hbar.ts | 42 ++++-- modules/sdk-coin-hbar/test/unit/hbar.ts | 186 +++++++++++++++++++++++- 2 files changed, 208 insertions(+), 20 deletions(-) diff --git a/modules/sdk-coin-hbar/src/hbar.ts b/modules/sdk-coin-hbar/src/hbar.ts index b035118c10..cb22b35b8c 100644 --- a/modules/sdk-coin-hbar/src/hbar.ts +++ b/modules/sdk-coin-hbar/src/hbar.ts @@ -36,7 +36,6 @@ import { Hbar as HbarUnit, } from '@hashgraph/sdk'; import { PUBLIC_KEY_PREFIX } from './lib/keyPair'; - export interface HbarSignTransactionOptions extends SignTransactionOptions { txPrebuild: TransactionPrebuild; prv: string; @@ -87,9 +86,10 @@ export interface RecoveryOptions { maxFee?: string; nodeId?: string; startTime?: string; + tokenId?: string; } -interface RecoveryInfo { +export interface RecoveryInfo { id: string; tx: string; coin: string; @@ -376,28 +376,42 @@ export class Hbar extends BaseCoin { } const { address: destinationAddress, memoId } = Utils.getAddressDetails(params.recoveryDestination); - + const nodeId = params.nodeId ? params.nodeId : '0.0.3'; const client = this.getHbarClient(); - const balance = await this.getAccountBalance(params.rootAddress, client); + const fee = params.maxFee ? params.maxFee : '10000000'; // default fee to 1 hbar const nativeBalance = HbarUnit.fromString(balance.hbars).toTinybars().toString(); - const fee = params.maxFee ? params.maxFee : '10000000'; + const spendableAmount = new BigNumber(nativeBalance).minus(fee); - if (new BigNumber(nativeBalance).isZero() || new BigNumber(nativeBalance).isLessThanOrEqualTo(fee)) { - throw new Error('Insufficient balance to recover, got balance: ' + nativeBalance + ' fee: ' + fee); + let txBuilder; + if (!params.tokenId) { + if (spendableAmount.isZero() || spendableAmount.isNegative()) { + throw new Error(`Insufficient balance to recover, got balance: ${nativeBalance} fee: ${fee}`); + } + txBuilder = this.getBuilderFactory().getTransferBuilder(); + txBuilder.send({ address: destinationAddress, amount: spendableAmount.toString() }); + } else { + if (spendableAmount.isNegative()) { + throw new Error( + `Insufficient native balance to recover tokens, got native balance: ${nativeBalance} fee: ${fee}` + ); + } + const tokenBalance = balance.tokens.find((token) => token.tokenId === params.tokenId); + const token = Utils.getHederaTokenNameFromId(params.tokenId); + if (!token) { + throw new Error(`Unsupported token: ${params.tokenId}`); + } + if (!tokenBalance || new BigNumber(tokenBalance.balance).isZero()) { + throw new Error(`Insufficient balance to recover token: ${params.tokenId} for account: ${params.rootAddress}`); + } + txBuilder = this.getBuilderFactory().getTokenTransferBuilder(); + txBuilder.send({ address: destinationAddress, amount: tokenBalance.balance, tokenName: token.name }); } - const nodeId = params.nodeId ? params.nodeId : '0.0.3'; - - const spendableAmount = new BigNumber(nativeBalance).minus(fee).toString(); - - const txBuilder = this.getBuilderFactory().getTransferBuilder(); txBuilder.node({ nodeId }); txBuilder.fee({ fee }); txBuilder.source({ address: params.rootAddress }); - txBuilder.send({ address: destinationAddress, amount: spendableAmount }); txBuilder.validDuration(180); - if (memoId) { txBuilder.memo(memoId); } diff --git a/modules/sdk-coin-hbar/test/unit/hbar.ts b/modules/sdk-coin-hbar/test/unit/hbar.ts index a4a8991ceb..6cdfb0050b 100644 --- a/modules/sdk-coin-hbar/test/unit/hbar.ts +++ b/modules/sdk-coin-hbar/test/unit/hbar.ts @@ -620,6 +620,7 @@ describe('Hedera Hashgraph:', function () { const balance = '1000000000'; const formatBalanceResponse = (balance: string) => new BigNumber(balance).dividedBy(basecoin.getBaseFactor()).toFixed(9) + ' ℏ'; + const tokenId = '0.0.13078'; describe('Non-BitGo', async function () { const sandBox = Sinon.createSandbox(); @@ -781,23 +782,144 @@ describe('Hedera Hashgraph:', function () { } ); }); + + it('should build and sign the recovery tx for tokens', async function () { + const balance = '100'; + const data = { + hbars: '1', + tokens: [{ tokenId: tokenId, balance: balance, decimals: 6 }], + }; + const getBalanceStub = sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); + + const recovery = await basecoin.recover({ + userKey, + backupKey, + rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: tokenId, + }); + + recovery.should.not.be.undefined(); + recovery.should.have.property('id'); + recovery.should.have.property('tx'); + recovery.should.have.property('coin', 'thbar'); + recovery.should.have.property('nodeId', defaultNodeId); + getBalanceStub.callCount.should.equal(1); + const txBuilder = basecoin.getBuilderFactory().from(recovery.tx); + const tx = await txBuilder.build(); + tx.toBroadcastFormat().should.equal(recovery.tx); + const txJson = tx.toJson(); + txJson.amount.should.equal(balance); + txJson.to.should.equal(recoveryDestination); + txJson.from.should.equal(rootAddress); + txJson.fee.should.equal(defaultFee); + txJson.node.should.equal(defaultNodeId); + txJson.memo.should.equal(memo); + txJson.validDuration.should.equal(defaultValidDuration); + txJson.should.have.property('startTime'); + recovery.should.have.property('startTime', txJson.startTime); + recovery.should.have.property('id', rootAddress + '@' + txJson.startTime); + }); + + it('should throw error for non supported invalid tokenId', async function () { + const invalidTokenId = 'randomstring'; + const data = { + hbars: '1', + tokens: [{ tokenId: tokenId, balance: '100', decimals: 6 }], + }; + sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); + await assert.rejects( + async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress: rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: invalidTokenId, + }); + }, + { message: 'Unsupported token: ' + invalidTokenId } + ); + }); + + it('should throw error for insufficient balance for tokenId if token balance not exist', async function () { + const data = { + hbars: '100', + tokens: [{ tokenId: 'randomString', balance: '100', decimals: 6 }], + }; + sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); + await assert.rejects( + async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress: rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: tokenId, + }); + }, + { message: 'Insufficient balance to recover token: ' + tokenId + ' for account: ' + rootAddress } + ); + }); + + it('should throw error for insufficient balance for tokenId if token balance exist with 0 amount', async function () { + const data = { + hbars: '100', + tokens: [{ tokenId: 'randomString', balance: '0', decimals: 6 }], + }; + sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); + await assert.rejects( + async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress: rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: tokenId, + }); + }, + { message: 'Insufficient balance to recover token: ' + tokenId + ' for account: ' + rootAddress } + ); + }); + + it('should throw error for insufficient native balance for token transfer', async function () { + const data = { + hbars: '0.01', + tokens: [{ tokenId: tokenId, balance: '10', decimals: 6 }], + }; + sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); + await assert.rejects( + async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress: rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: tokenId, + }); + }, + { message: 'Insufficient native balance to recover tokens, got native balance: 1000000 fee: ' + defaultFee } + ); + }); }); describe('Unsigned Sweep', function () { const sandBox = Sinon.createSandbox(); let getBalanceStub: SinonStub; - beforeEach(function () { - getBalanceStub = sandBox - .stub(Hbar.prototype, 'getAccountBalance') - .resolves({ hbars: formatBalanceResponse(balance), tokens: [] }); - }); - afterEach(function () { sandBox.verifyAndRestore(); }); it('should build unsigned sweep tx', async function () { + getBalanceStub = sandBox + .stub(Hbar.prototype, 'getAccountBalance') + .resolves({ hbars: formatBalanceResponse(balance), tokens: [] }); const startTime = (Date.now() / 1000 + 10).toFixed(); // timestamp in seconds, 10 seconds from now const expectedAmount = new BigNumber(balance).minus(defaultFee).toString(); @@ -842,6 +964,58 @@ describe('Hedera Hashgraph:', function () { txJson.validDuration.should.equal(defaultValidDuration); }); + it('should build unsigned sweep tx for tokens', async function () { + const balance = '100'; + const data = { + hbars: '1', + tokens: [{ tokenId: tokenId, balance: balance, decimals: 6 }], + }; + getBalanceStub = sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); + const startTime = (Date.now() / 1000 + 10).toFixed(); // timestamp in seconds, 10 seconds from now + const recovery = await basecoin.recover({ + userKey: userPub, + backupKey: backupPub, + rootAddress, + bitgoKey, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + startTime, + tokenId: tokenId, + }); + + getBalanceStub.callCount.should.equal(1); + + recovery.should.not.be.undefined(); + recovery.should.have.property('txHex'); + recovery.should.have.property('id', rootAddress + '@' + startTime + '.0'); + recovery.should.have.property('userKey', userPub); + recovery.should.have.property('backupKey', backupPub); + recovery.should.have.property('bitgoKey', bitgoKey); + recovery.should.have.property('address', rootAddress); + recovery.should.have.property('coin', 'thbar'); + recovery.should.have.property('maxFee', defaultFee.toString()); + recovery.should.have.property('recipients', [ + { address: recoveryDestination, amount: balance, tokenName: 'thbar:usdc' }, + ]); + recovery.should.have.property('amount', balance); + recovery.should.have.property('validDuration', defaultValidDuration); + recovery.should.have.property('nodeId', defaultNodeId); + recovery.should.have.property('memo', memo); + recovery.should.have.property('startTime', startTime + '.0'); + const txBuilder = basecoin.getBuilderFactory().from(recovery.txHex); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + txJson.id.should.equal(rootAddress + '@' + startTime + '.0'); + txJson.amount.should.equal(balance); + txJson.to.should.equal(recoveryDestination); + txJson.from.should.equal(rootAddress); + txJson.fee.should.equal(defaultFee); + txJson.node.should.equal(defaultNodeId); + txJson.memo.should.equal(memo); + txJson.validDuration.should.equal(defaultValidDuration); + txJson.startTime.should.equal(startTime + '.0'); + txJson.validDuration.should.equal(defaultValidDuration); + }); + it('should throw if startTime is undefined', async function () { const startTime = undefined;