Skip to content
Draft
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
130 changes: 130 additions & 0 deletions modules/bitgo/test/v2/unit/staking/stakingWalletNonTSS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,136 @@ describe('non-TSS Staking Wallet', function () {
});
});

describe('HBAR Claim Rewards Validation', function () {
let thbarBaseCoin;
let thbarStakingWallet: StakingWallet;
let thbarStakingWalletData: WalletData;

before(function () {
thbarBaseCoin = bitgo.coin('thbar');
thbarStakingWalletData = {
approvalsRequired: 0,
balance: 0,
balanceString: '',
coinSpecific: {} as WalletCoinSpecific,
confirmedBalance: 0,
confirmedBalanceString: '',
keys: [],
label: '',
multisigType: 'onchain',
pendingApprovals: [],
spendableBalance: 0,
spendableBalanceString: '',
id: 'thbarStakingWalletId',
coin: 'thbar',
enterprise: enterprise.id,
};
thbarStakingWallet = new Wallet(bitgo, thbarBaseCoin, thbarStakingWalletData).toStakingWallet();
});

it('should not throw amount mismatch for hbar claim rewards self-transfer', async function () {
const stakingTransaction: StakingTransaction = {
id: 'tx-1',
stakingRequestId: 'req-1',
delegationId: 'del-1',
transactionType: 'claim_rewards',
createdDate: '2026-05-15T00:00:00Z',
status: 'READY',
statusModifiedDate: '2026-05-15T00:00:00Z',
amount: '1',
buildParams: {
recipients: [
{
amount: '1',
address: '0.0.8933725',
},
],
type: 'stakeClaimRewards',
},
};

// Stub explainTransaction to return outputs with amount "0" (merged self-transfer)
sinon.stub(thbarBaseCoin, 'explainTransaction').resolves({
id: 'tx-hash',
outputs: [
{
address: '0.0.8933725',
amount: '0',
coin: 'thbar',
},
],
outputAmount: '0',
changeAmount: '0',
fee: { fee: '500000' },
changeOutputs: [],
});

// Stub sign to prevent actual signing
sinon.stub(thbarStakingWallet, 'sign').resolves();

// Stub build to return a prebuild with txHex
sinon.stub(thbarStakingWallet, 'build' as any).resolves({
transaction: stakingTransaction,
result: {
txHex: 'fake-hbar-tx-hex',
walletId: thbarStakingWalletData.id,
},
});

// Should NOT throw -- the amount mismatch (1 vs 0) is expected for hbar claim rewards
await thbarStakingWallet.buildAndSign({ walletPassphrase: 'passphrase' }, stakingTransaction);
});

it('should still throw amount mismatch for non-claim-rewards hbar transactions', async function () {
const stakingTransaction: StakingTransaction = {
id: 'tx-2',
stakingRequestId: 'req-2',
delegationId: 'del-2',
transactionType: 'delegate',
createdDate: '2026-05-15T00:00:00Z',
status: 'READY',
statusModifiedDate: '2026-05-15T00:00:00Z',
amount: '100',
buildParams: {
recipients: [
{
amount: '100',
address: '0.0.8933725',
},
],
type: 'stakeAccountUpdate',
},
};

sinon.stub(thbarBaseCoin, 'explainTransaction').resolves({
id: 'tx-hash',
outputs: [
{
address: '0.0.8933725',
amount: '50',
coin: 'thbar',
},
],
outputAmount: '50',
changeAmount: '0',
fee: { fee: '500000' },
changeOutputs: [],
});

sinon.stub(thbarStakingWallet, 'build' as any).resolves({
transaction: stakingTransaction,
result: {
txHex: 'fake-hbar-tx-hex',
walletId: thbarStakingWalletData.id,
},
});

await thbarStakingWallet
.buildAndSign({ walletPassphrase: 'passphrase' }, stakingTransaction)
.should.be.rejectedWith(/amount mismatch.*Expected: 100.*Got: 50/);
});
});

describe('TAVAXP Staking', function () {
it('should build and validate transaction', async function () {
const unsignedTransaction: PrebuildTransactionResult = {
Expand Down
19 changes: 16 additions & 3 deletions modules/sdk-core/src/bitgo/staking/stakingWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ export class StakingWallet implements IStakingWallet {
return this.wallet.baseCoin.getFamily() === 'trx';
}

private isHbarClaimRewards(transaction: StakingTransaction) {
return this.wallet.baseCoin.getFamily() === 'hbar' && transaction.transactionType.toLowerCase() === 'claim_rewards';
}

/**
* Sign the staking transaction
* @param signOptions
Expand Down Expand Up @@ -433,9 +437,18 @@ export class StakingWallet implements IStakingWallet {
const matchResult = transactionRecipientsMatch(userRecipient, platformRecipient);

if (!matchResult.amountMatch) {
mismatchErrors.push(
`Recipient ${address} amount mismatch. Expected: ${userRecipient.amount}, Got: ${platformRecipient.amount}`
);
// HBAR claim rewards uses a self-transfer (sender == recipient) of 1 tinybar.
// The wire format merges [{acct, -1}, {acct, +1}] into [{acct, 0}], so the
// explained amount is "0" while buildParams amount is "1". This mismatch is
// expected and safe -- skip the amount error for this specific case.
const isHbarSelfTransferClaim =
this.isHbarClaimRewards(transaction) &&
userRecipient.address.toLowerCase() === platformRecipient.address.toLowerCase();
if (!isHbarSelfTransferClaim) {
mismatchErrors.push(
`Recipient ${address} amount mismatch. Expected: ${userRecipient.amount}, Got: ${platformRecipient.amount}`
);
}
}
if (!matchResult.tokenMatch) {
mismatchErrors.push(
Expand Down
Loading