Skip to content

Commit

Permalink
refactor(sdk-coin-hbar): add recovery for hbar tokens
Browse files Browse the repository at this point in the history
TICKET: WIN-4342
  • Loading branch information
abhishekagrawal080 committed Feb 5, 2025
1 parent 4b7a8f8 commit 9d117fa
Show file tree
Hide file tree
Showing 3 changed files with 3,909 additions and 3,462 deletions.
14 changes: 11 additions & 3 deletions modules/sdk-coin-hbar/src/hbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export interface RecoveryOptions {
startTime?: string;
}

interface RecoveryInfo {
export interface RecoveryInfo {
id: string;
tx: string;
coin: string;
Expand Down Expand Up @@ -391,12 +391,20 @@ export class Hbar extends BaseCoin {

const spendableAmount = new BigNumber(nativeBalance).minus(fee).toString();

const txBuilder = this.getBuilderFactory().getTransferBuilder();
const coinConfig = coins.get(this.getChain());
const txBuilder = coinConfig.isToken
? this.getBuilderFactory().getTokenTransferBuilder()
: this.getBuilderFactory().getTransferBuilder();

txBuilder.node({ nodeId });
txBuilder.fee({ fee });
txBuilder.source({ address: params.rootAddress });
txBuilder.send({ address: destinationAddress, amount: spendableAmount });
txBuilder.validDuration(180);
if (coinConfig.isToken) {
txBuilder.send({ address: destinationAddress, amount: spendableAmount, tokenName: coinConfig.name });
} else {
txBuilder.send({ address: destinationAddress, amount: spendableAmount });
}

if (memoId) {
txBuilder.memo(memoId);
Expand Down
340 changes: 339 additions & 1 deletion modules/sdk-coin-hbar/test/unit/hbarToken.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import 'should';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { BitGoAPI } from '@bitgo/sdk-api';
import { HbarToken } from '../../src';
import { HbarToken, RecoveryInfo, OfflineVaultTxInfo } from '../../src';
import BigNumber from 'bignumber.js';
import Sinon, { SinonStub } from 'sinon';
import assert from 'assert';

describe('Hedera Hashgraph Token', function () {
let bitgo: TestBitGoAPI;
let baseCoin;
let token: HbarToken;
const tokenName = 'thbar:usdc';

Expand All @@ -15,6 +19,7 @@ describe('Hedera Hashgraph Token', function () {
});
bitgo.initializeTestVars();
token = bitgo.coin(tokenName) as HbarToken;
baseCoin = bitgo.coin('thbar');
});

it('Return correct configurations', function () {
Expand All @@ -27,4 +32,337 @@ describe('Hedera Hashgraph Token', function () {
token.network.should.equal('Testnet');
token.decimalPlaces.should.equal(6);
});

describe('Recovery', function () {
const defaultValidDuration = '180';
const defaultFee = 10000000;
const defaultNodeId = '0.0.3';
const userKey =
'{"iv":"WlPuJOejRWgj/NTd3UMgrw==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"6yAVFvreHSQ=","ct":"8j/lBVkFByKlVhaS9JWmmLja5yTokjaIiLDxMIDjMojVEim9T36WAm5qW6v1V0A7QcEuGiVl3PKMDa+Gr6tI/HT58DW5RE+pHzya9MUQpAgNrJr8VEWjrXWqZECVtra1/bKCyB+mozc="}';
const userPub = '302a300506032b6570032100ddd53a1591d72b181109bd3e57b18603740490b9ab4d37bc7fa27480e6b8c911';
const backupKey =
'{"iv":"D5DVDozQx9B02JeFV0/OVA==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"7FUNF8M35bo=","ct":"ZiPsu5Qe/AIS4JQXt+rrusHnYCy4CqurM16R5wJrd4CEx7u85y3yy5ErnsdyYYcc3txyNmUIQ2/CBq/LKoKO/VIeU++CnKxzGuHGcNI47BPk3RQK42a66uIQn/yTR++XgdK1KhvUL3U="}';
const backupPub = '302a300506032b65700321006293e4ec9bf1b2d8fae631119107248a65e2207a05d32a11f42cc3d9a3005d4a';
const rootAddress = '0.0.7671186';
const walletPassphrase = 'TestPasswordPleaseIgnore';
const recoveryDestination = '0.0.7651908';
const bitgoKey = '5a93b01ea87e963f61c974a89d62e3841392f1ba020fbbcc65a8089ca025abbb';
const memo = '4';
const balance = '1000000000';
const formatBalanceResponse = (balance: string) =>
new BigNumber(balance).dividedBy(baseCoin.getBaseFactor()).toFixed(9) + ' ℏ';

describe('Non-BitGo', async function () {
const sandBox = Sinon.createSandbox();

afterEach(function () {
sandBox.verifyAndRestore();
});

it('should build and sign the recovery tx for tokens', async function () {
const expectedAmount = new BigNumber(balance).minus(defaultFee).toString();
const getBalanceStub = sandBox
.stub(HbarToken.prototype, 'getAccountBalance')
.resolves({ hbars: formatBalanceResponse(balance), tokens: [] });

const recovery = await token.recover({
userKey,
backupKey,
rootAddress,
walletPassphrase,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
});
recovery.should.not.be.undefined();
recovery.should.have.property('id');
recovery.should.have.property('tx');
recovery.should.have.property('coin', tokenName);
recovery.should.have.property('nodeId', defaultNodeId);
getBalanceStub.callCount.should.equal(1);
const txBuilder = baseCoin.getBuilderFactory().from((recovery as RecoveryInfo).tx);
const tx = await txBuilder.build();
tx.toBroadcastFormat().should.equal((recovery as RecoveryInfo).tx);
const txJson = tx.toJson();
txJson.amount.should.equal(expectedAmount);
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 for invalid rootAddress', async function () {
const invalidRootAddress = 'randomstring';
await assert.rejects(
async () => {
await token.recover({
userKey,
backupKey,
rootAddress: 'randomstring',
walletPassphrase,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
});
},
{ message: 'invalid rootAddress, got: ' + invalidRootAddress }
);
});

it('should throw for invalid recoveryDestination', async function () {
const invalidRecoveryDestination = 'randomstring';
await assert.rejects(
async () => {
await token.recover({
userKey,
backupKey,
rootAddress,
walletPassphrase,
recoveryDestination: 'randomstring',
});
},
{ message: 'invalid recoveryDestination, got: ' + invalidRecoveryDestination }
);
});

it('should throw for invalid nodeId', async function () {
const invalidNodeId = 'a.2.3';
await assert.rejects(
async () => {
await token.recover({
userKey,
backupKey,
rootAddress,
walletPassphrase,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
nodeId: invalidNodeId,
});
},
{ message: 'invalid nodeId, got: ' + invalidNodeId }
);
});

it('should throw for invalid maxFee', async function () {
const invalidMaxFee = '-32';
await assert.rejects(
async () => {
await token.recover({
userKey,
backupKey,
rootAddress,
walletPassphrase,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
maxFee: invalidMaxFee,
});
},
{ message: 'invalid maxFee, got: ' + invalidMaxFee }
);
});

it('should throw if there is no enough balance to recover', async function () {
const getBalanceStub = sandBox
.stub(HbarToken.prototype, 'getAccountBalance')
.resolves({ hbars: formatBalanceResponse('100'), tokens: [] });
await assert.rejects(
async () => {
await token.recover({
userKey,
backupKey,
rootAddress,
walletPassphrase,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
});
},
{ message: 'Insufficient balance to recover, got balance: 100 fee: 10000000' }
);

getBalanceStub.callCount.should.equal(1);
});

it('should throw if the walletPassphrase is undefined', async function () {
await assert.rejects(
async () => {
await token.recover({
userKey,
backupKey,
rootAddress,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
});
},
{ message: 'walletPassphrase is required for non-bitgo recovery' }
);
});

it('should throw if the walletPassphrase is wrong', async function () {
await assert.rejects(
async () => {
await baseCoin.recover({
userKey,
backupKey,
rootAddress,
walletPassphrase: 'randompassword',
recoveryDestination: recoveryDestination + '?memoId=' + memo,
});
},
{
message:
"unable to decrypt userKey or backupKey with the walletPassphrase provided, got error: password error - ccm: tag doesn't match",
}
);
});
});

describe('Unsigned Sweep', function () {
const sandBox = Sinon.createSandbox();
let getBalanceStub: SinonStub;

beforeEach(function () {
getBalanceStub = sandBox
.stub(HbarToken.prototype, 'getAccountBalance')
.resolves({ hbars: formatBalanceResponse(balance), tokens: [] });
});

afterEach(function () {
sandBox.verifyAndRestore();
});

it('should build unsigned sweep tx', async function () {
const startTime = (Date.now() / 1000 + 10).toFixed(); // timestamp in seconds, 10 seconds from now
const expectedAmount = new BigNumber(balance).minus(defaultFee).toString();

const recovery = await token.recover({
userKey: userPub,
backupKey: backupPub,
rootAddress,
bitgoKey,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
startTime,
});

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', tokenName);
recovery.should.have.property('maxFee', defaultFee.toString());
recovery.should.have.property('recipients', [
{ address: recoveryDestination, amount: expectedAmount, tokenName: tokenName },
]);
recovery.should.have.property('amount', expectedAmount);
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 as OfflineVaultTxInfo).txHex);
const tx = await txBuilder.build();
const txJson = tx.toJson();
txJson.id.should.equal(rootAddress + '@' + startTime + '.0');
txJson.amount.should.equal(expectedAmount);
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;

await assert.rejects(
async () => {
await token.recover({
userKey: userPub,
backupKey: backupPub,
rootAddress,
bitgoKey,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
startTime,
});
},
{ message: 'start time is required for unsigned sweep' }
);
});

it('should throw for invalid userKey', async function () {
const startTime = (Date.now() / 1000 + 10).toFixed();
const invalidUserPub = '302a300506032b6570032100randomstring';
await assert.rejects(
async () => {
await token.recover({
userKey: invalidUserPub,
backupKey: backupPub,
bitgoKey,
rootAddress,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
startTime,
});
},
{ message: 'invalid userKey, got: ' + invalidUserPub }
);
});

it('should throw for invalid backupKey', async function () {
const invalidBackupPub = '302a300506032b6570032100randomstring';
const startTime = (Date.now() / 1000 + 10).toFixed();
await assert.rejects(
async () => {
await token.recover({
userKey: userPub,
backupKey: invalidBackupPub,
bitgoKey,
rootAddress,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
startTime,
});
},
{ message: 'invalid backupKey, got: ' + invalidBackupPub }
);
});

it('should throw if startTime is a valid timestamp', async function () {
const startTime = 'asd';

await assert.rejects(
async () => {
await token.recover({
userKey: userPub,
backupKey: backupPub,
rootAddress,
bitgoKey,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
startTime,
});
},
{ message: 'invalid startTime, got: ' + startTime }
);
});

it('should throw if startTime is not a future date', async function () {
const startTime = (Date.now() / 1000 - 1).toString(); // timestamp in seconds, 1 second ago

await assert.rejects(
async () => {
await token.recover({
userKey: userPub,
backupKey: backupPub,
rootAddress,
bitgoKey,
recoveryDestination: recoveryDestination + '?memoId=' + memo,
startTime,
});
},
{ message: 'startTime must be a future timestamp, got: ' + startTime }
);
});
});
});
});
Loading

0 comments on commit 9d117fa

Please sign in to comment.