Skip to content

Commit 9393669

Browse files
Merge pull request #5463 from BitGo/WIN-4342
refactor(sdk-coin-hbar): add recovery for hbar tokens
2 parents c141817 + 1fa115f commit 9393669

File tree

2 files changed

+208
-20
lines changed

2 files changed

+208
-20
lines changed

modules/sdk-coin-hbar/src/hbar.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import {
3636
Hbar as HbarUnit,
3737
} from '@hashgraph/sdk';
3838
import { PUBLIC_KEY_PREFIX } from './lib/keyPair';
39-
4039
export interface HbarSignTransactionOptions extends SignTransactionOptions {
4140
txPrebuild: TransactionPrebuild;
4241
prv: string;
@@ -87,9 +86,10 @@ export interface RecoveryOptions {
8786
maxFee?: string;
8887
nodeId?: string;
8988
startTime?: string;
89+
tokenId?: string;
9090
}
9191

92-
interface RecoveryInfo {
92+
export interface RecoveryInfo {
9393
id: string;
9494
tx: string;
9595
coin: string;
@@ -376,28 +376,42 @@ export class Hbar extends BaseCoin {
376376
}
377377

378378
const { address: destinationAddress, memoId } = Utils.getAddressDetails(params.recoveryDestination);
379-
379+
const nodeId = params.nodeId ? params.nodeId : '0.0.3';
380380
const client = this.getHbarClient();
381-
382381
const balance = await this.getAccountBalance(params.rootAddress, client);
382+
const fee = params.maxFee ? params.maxFee : '10000000'; // default fee to 1 hbar
383383
const nativeBalance = HbarUnit.fromString(balance.hbars).toTinybars().toString();
384-
const fee = params.maxFee ? params.maxFee : '10000000';
384+
const spendableAmount = new BigNumber(nativeBalance).minus(fee);
385385

386-
if (new BigNumber(nativeBalance).isZero() || new BigNumber(nativeBalance).isLessThanOrEqualTo(fee)) {
387-
throw new Error('Insufficient balance to recover, got balance: ' + nativeBalance + ' fee: ' + fee);
386+
let txBuilder;
387+
if (!params.tokenId) {
388+
if (spendableAmount.isZero() || spendableAmount.isNegative()) {
389+
throw new Error(`Insufficient balance to recover, got balance: ${nativeBalance} fee: ${fee}`);
390+
}
391+
txBuilder = this.getBuilderFactory().getTransferBuilder();
392+
txBuilder.send({ address: destinationAddress, amount: spendableAmount.toString() });
393+
} else {
394+
if (spendableAmount.isNegative()) {
395+
throw new Error(
396+
`Insufficient native balance to recover tokens, got native balance: ${nativeBalance} fee: ${fee}`
397+
);
398+
}
399+
const tokenBalance = balance.tokens.find((token) => token.tokenId === params.tokenId);
400+
const token = Utils.getHederaTokenNameFromId(params.tokenId);
401+
if (!token) {
402+
throw new Error(`Unsupported token: ${params.tokenId}`);
403+
}
404+
if (!tokenBalance || new BigNumber(tokenBalance.balance).isZero()) {
405+
throw new Error(`Insufficient balance to recover token: ${params.tokenId} for account: ${params.rootAddress}`);
406+
}
407+
txBuilder = this.getBuilderFactory().getTokenTransferBuilder();
408+
txBuilder.send({ address: destinationAddress, amount: tokenBalance.balance, tokenName: token.name });
388409
}
389410

390-
const nodeId = params.nodeId ? params.nodeId : '0.0.3';
391-
392-
const spendableAmount = new BigNumber(nativeBalance).minus(fee).toString();
393-
394-
const txBuilder = this.getBuilderFactory().getTransferBuilder();
395411
txBuilder.node({ nodeId });
396412
txBuilder.fee({ fee });
397413
txBuilder.source({ address: params.rootAddress });
398-
txBuilder.send({ address: destinationAddress, amount: spendableAmount });
399414
txBuilder.validDuration(180);
400-
401415
if (memoId) {
402416
txBuilder.memo(memoId);
403417
}

modules/sdk-coin-hbar/test/unit/hbar.ts

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ describe('Hedera Hashgraph:', function () {
620620
const balance = '1000000000';
621621
const formatBalanceResponse = (balance: string) =>
622622
new BigNumber(balance).dividedBy(basecoin.getBaseFactor()).toFixed(9) + ' ℏ';
623+
const tokenId = '0.0.13078';
623624

624625
describe('Non-BitGo', async function () {
625626
const sandBox = Sinon.createSandbox();
@@ -781,23 +782,144 @@ describe('Hedera Hashgraph:', function () {
781782
}
782783
);
783784
});
785+
786+
it('should build and sign the recovery tx for tokens', async function () {
787+
const balance = '100';
788+
const data = {
789+
hbars: '1',
790+
tokens: [{ tokenId: tokenId, balance: balance, decimals: 6 }],
791+
};
792+
const getBalanceStub = sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data);
793+
794+
const recovery = await basecoin.recover({
795+
userKey,
796+
backupKey,
797+
rootAddress,
798+
walletPassphrase,
799+
recoveryDestination: recoveryDestination + '?memoId=' + memo,
800+
tokenId: tokenId,
801+
});
802+
803+
recovery.should.not.be.undefined();
804+
recovery.should.have.property('id');
805+
recovery.should.have.property('tx');
806+
recovery.should.have.property('coin', 'thbar');
807+
recovery.should.have.property('nodeId', defaultNodeId);
808+
getBalanceStub.callCount.should.equal(1);
809+
const txBuilder = basecoin.getBuilderFactory().from(recovery.tx);
810+
const tx = await txBuilder.build();
811+
tx.toBroadcastFormat().should.equal(recovery.tx);
812+
const txJson = tx.toJson();
813+
txJson.amount.should.equal(balance);
814+
txJson.to.should.equal(recoveryDestination);
815+
txJson.from.should.equal(rootAddress);
816+
txJson.fee.should.equal(defaultFee);
817+
txJson.node.should.equal(defaultNodeId);
818+
txJson.memo.should.equal(memo);
819+
txJson.validDuration.should.equal(defaultValidDuration);
820+
txJson.should.have.property('startTime');
821+
recovery.should.have.property('startTime', txJson.startTime);
822+
recovery.should.have.property('id', rootAddress + '@' + txJson.startTime);
823+
});
824+
825+
it('should throw error for non supported invalid tokenId', async function () {
826+
const invalidTokenId = 'randomstring';
827+
const data = {
828+
hbars: '1',
829+
tokens: [{ tokenId: tokenId, balance: '100', decimals: 6 }],
830+
};
831+
sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data);
832+
await assert.rejects(
833+
async () => {
834+
await basecoin.recover({
835+
userKey,
836+
backupKey,
837+
rootAddress: rootAddress,
838+
walletPassphrase,
839+
recoveryDestination: recoveryDestination + '?memoId=' + memo,
840+
tokenId: invalidTokenId,
841+
});
842+
},
843+
{ message: 'Unsupported token: ' + invalidTokenId }
844+
);
845+
});
846+
847+
it('should throw error for insufficient balance for tokenId if token balance not exist', async function () {
848+
const data = {
849+
hbars: '100',
850+
tokens: [{ tokenId: 'randomString', balance: '100', decimals: 6 }],
851+
};
852+
sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data);
853+
await assert.rejects(
854+
async () => {
855+
await basecoin.recover({
856+
userKey,
857+
backupKey,
858+
rootAddress: rootAddress,
859+
walletPassphrase,
860+
recoveryDestination: recoveryDestination + '?memoId=' + memo,
861+
tokenId: tokenId,
862+
});
863+
},
864+
{ message: 'Insufficient balance to recover token: ' + tokenId + ' for account: ' + rootAddress }
865+
);
866+
});
867+
868+
it('should throw error for insufficient balance for tokenId if token balance exist with 0 amount', async function () {
869+
const data = {
870+
hbars: '100',
871+
tokens: [{ tokenId: 'randomString', balance: '0', decimals: 6 }],
872+
};
873+
sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data);
874+
await assert.rejects(
875+
async () => {
876+
await basecoin.recover({
877+
userKey,
878+
backupKey,
879+
rootAddress: rootAddress,
880+
walletPassphrase,
881+
recoveryDestination: recoveryDestination + '?memoId=' + memo,
882+
tokenId: tokenId,
883+
});
884+
},
885+
{ message: 'Insufficient balance to recover token: ' + tokenId + ' for account: ' + rootAddress }
886+
);
887+
});
888+
889+
it('should throw error for insufficient native balance for token transfer', async function () {
890+
const data = {
891+
hbars: '0.01',
892+
tokens: [{ tokenId: tokenId, balance: '10', decimals: 6 }],
893+
};
894+
sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data);
895+
await assert.rejects(
896+
async () => {
897+
await basecoin.recover({
898+
userKey,
899+
backupKey,
900+
rootAddress: rootAddress,
901+
walletPassphrase,
902+
recoveryDestination: recoveryDestination + '?memoId=' + memo,
903+
tokenId: tokenId,
904+
});
905+
},
906+
{ message: 'Insufficient native balance to recover tokens, got native balance: 1000000 fee: ' + defaultFee }
907+
);
908+
});
784909
});
785910

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

790-
beforeEach(function () {
791-
getBalanceStub = sandBox
792-
.stub(Hbar.prototype, 'getAccountBalance')
793-
.resolves({ hbars: formatBalanceResponse(balance), tokens: [] });
794-
});
795-
796915
afterEach(function () {
797916
sandBox.verifyAndRestore();
798917
});
799918

800919
it('should build unsigned sweep tx', async function () {
920+
getBalanceStub = sandBox
921+
.stub(Hbar.prototype, 'getAccountBalance')
922+
.resolves({ hbars: formatBalanceResponse(balance), tokens: [] });
801923
const startTime = (Date.now() / 1000 + 10).toFixed(); // timestamp in seconds, 10 seconds from now
802924
const expectedAmount = new BigNumber(balance).minus(defaultFee).toString();
803925

@@ -842,6 +964,58 @@ describe('Hedera Hashgraph:', function () {
842964
txJson.validDuration.should.equal(defaultValidDuration);
843965
});
844966

967+
it('should build unsigned sweep tx for tokens', async function () {
968+
const balance = '100';
969+
const data = {
970+
hbars: '1',
971+
tokens: [{ tokenId: tokenId, balance: balance, decimals: 6 }],
972+
};
973+
getBalanceStub = sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data);
974+
const startTime = (Date.now() / 1000 + 10).toFixed(); // timestamp in seconds, 10 seconds from now
975+
const recovery = await basecoin.recover({
976+
userKey: userPub,
977+
backupKey: backupPub,
978+
rootAddress,
979+
bitgoKey,
980+
recoveryDestination: recoveryDestination + '?memoId=' + memo,
981+
startTime,
982+
tokenId: tokenId,
983+
});
984+
985+
getBalanceStub.callCount.should.equal(1);
986+
987+
recovery.should.not.be.undefined();
988+
recovery.should.have.property('txHex');
989+
recovery.should.have.property('id', rootAddress + '@' + startTime + '.0');
990+
recovery.should.have.property('userKey', userPub);
991+
recovery.should.have.property('backupKey', backupPub);
992+
recovery.should.have.property('bitgoKey', bitgoKey);
993+
recovery.should.have.property('address', rootAddress);
994+
recovery.should.have.property('coin', 'thbar');
995+
recovery.should.have.property('maxFee', defaultFee.toString());
996+
recovery.should.have.property('recipients', [
997+
{ address: recoveryDestination, amount: balance, tokenName: 'thbar:usdc' },
998+
]);
999+
recovery.should.have.property('amount', balance);
1000+
recovery.should.have.property('validDuration', defaultValidDuration);
1001+
recovery.should.have.property('nodeId', defaultNodeId);
1002+
recovery.should.have.property('memo', memo);
1003+
recovery.should.have.property('startTime', startTime + '.0');
1004+
const txBuilder = basecoin.getBuilderFactory().from(recovery.txHex);
1005+
const tx = await txBuilder.build();
1006+
const txJson = tx.toJson();
1007+
txJson.id.should.equal(rootAddress + '@' + startTime + '.0');
1008+
txJson.amount.should.equal(balance);
1009+
txJson.to.should.equal(recoveryDestination);
1010+
txJson.from.should.equal(rootAddress);
1011+
txJson.fee.should.equal(defaultFee);
1012+
txJson.node.should.equal(defaultNodeId);
1013+
txJson.memo.should.equal(memo);
1014+
txJson.validDuration.should.equal(defaultValidDuration);
1015+
txJson.startTime.should.equal(startTime + '.0');
1016+
txJson.validDuration.should.equal(defaultValidDuration);
1017+
});
1018+
8451019
it('should throw if startTime is undefined', async function () {
8461020
const startTime = undefined;
8471021

0 commit comments

Comments
 (0)