Skip to content

Commit

Permalink
Merge pull request #5463 from BitGo/WIN-4342
Browse files Browse the repository at this point in the history
refactor(sdk-coin-hbar): add recovery for hbar tokens
  • Loading branch information
abhishekagrawal080 authored Feb 7, 2025
2 parents c141817 + 1fa115f commit 9393669
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 20 deletions.
42 changes: 28 additions & 14 deletions modules/sdk-coin-hbar/src/hbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
186 changes: 180 additions & 6 deletions modules/sdk-coin-hbar/test/unit/hbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;

Expand Down

0 comments on commit 9393669

Please sign in to comment.