Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(sdk-coin-hbar): add recovery for hbar tokens #5463

Merged
merged 1 commit into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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