diff --git a/modules/sdk-coin-hbar/src/hbar.ts b/modules/sdk-coin-hbar/src/hbar.ts index 58d4f5b788..76f4d91754 100644 --- a/modules/sdk-coin-hbar/src/hbar.ts +++ b/modules/sdk-coin-hbar/src/hbar.ts @@ -28,7 +28,7 @@ import { import { BigNumber } from 'bignumber.js'; import * as stellar from 'stellar-sdk'; import { SeedValidator } from './seedValidator'; -import { KeyPair as HbarKeyPair, TransactionBuilderFactory, Transaction } from './lib'; +import { KeyPair as HbarKeyPair, TransactionBuilderFactory, Transaction, Recipient as HederaRecipient } from './lib'; import * as Utils from './lib/utils'; import * as _ from 'lodash'; import { @@ -39,6 +39,31 @@ import { Hbar as HbarUnit, } from '@hashgraph/sdk'; import { PUBLIC_KEY_PREFIX } from './lib/keyPair'; + +// Hedera-specific transaction data interface for raw transaction validation +interface HederaRawTransactionData { + id?: string; + from?: string; + fee?: number; + startTime?: string; + validDuration?: string; + node?: string; + memo?: string; + amount?: string; + instructionsData?: { + type?: string; + accountId?: string; + params?: { + accountId?: string; + tokenNames?: string[]; + recipients?: Array<{ + address: string; + amount: string; + tokenName?: string; + }>; + }; + }; +} export interface HbarSignTransactionOptions extends SignTransactionOptions { txPrebuild: TransactionPrebuild; prv: string; @@ -224,10 +249,202 @@ export class Hbar extends BaseCoin { return Utils.isSameBaseAddress(address, baseAddress); } + /** + * Verify a token enablement transaction with strict validation + * @param txHex - The transaction hex to verify + * @param expectedToken - Object containing tokenId (preferred) or tokenName + * @param expectedAccountId - The expected account ID that will enable the token + * @throws Error if the transaction is not a valid token enablement transaction + */ + async verifyTokenEnablementTransaction( + txHex: string, + expectedToken: { tokenId?: string; tokenName?: string }, + expectedAccountId: string + ): Promise { + if (!txHex || !expectedAccountId || (!expectedToken.tokenId && !expectedToken.tokenName)) { + const missing: string[] = []; + if (!txHex) missing.push('txHex'); + if (!expectedAccountId) missing.push('expectedAccountId'); + if (!expectedToken.tokenId && !expectedToken.tokenName) missing.push('expectedToken.tokenId|tokenName'); + throw new Error(`Missing required parameters: ${missing.join(', ')}`); + } + + try { + const transaction = new Transaction(coins.get(this.getChain())); + transaction.fromRawTransaction(txHex); + const raw = transaction.toJson(); + + const explainedTx = await this.explainTransaction({ txHex }); + + this.validateTxStructureStrict(explainedTx); + this.validateNoTransfers(raw); + this.validateAccountIdMatches(explainedTx, raw, expectedAccountId); + this.validateTokenEnablementTarget(explainedTx, raw, expectedToken); + this.validateAssociateInstructionOnly(raw); + this.validateTxHexAgainstExpected(txHex, expectedToken, expectedAccountId); + } catch (error) { + throw new Error(`Invalid token enablement transaction: ${error.message}`); + } + } + + private validateTxStructureStrict(ex: TransactionExplanation): void { + if (!ex.outputs || ex.outputs.length === 0) { + throw new Error('Invalid token enablement transaction: missing required token association output'); + } + if (ex.outputs.length !== 1) { + throw new Error(`Expected exactly 1 output, got ${ex.outputs.length}`); + } + const out0 = ex.outputs[0]; + if (out0.amount !== '0') { + throw new Error(`Expected output amount '0', got ${out0.amount}`); + } + } + + private validateNoTransfers(raw: HederaRawTransactionData): void { + if (raw.instructionsData?.params?.recipients?.length && raw.instructionsData.params.recipients.length > 0) { + const hasNonZeroTransfers = raw.instructionsData.params.recipients.some( + (recipient: HederaRecipient) => recipient.amount && recipient.amount !== '0' + ); + if (hasNonZeroTransfers) { + throw new Error('Transaction contains transfers; not a pure token enablement.'); + } + } + + if (raw.amount && raw.amount !== '0') { + throw new Error('Transaction contains transfers; not a pure token enablement.'); + } + } + + private validateAccountIdMatches( + ex: TransactionExplanation, + raw: HederaRawTransactionData, + expectedAccountId: string + ): void { + if (ex.outputs && ex.outputs.length > 0) { + const out0 = ex.outputs[0]; + const normalizedOutput = Utils.getAddressDetails(out0.address).address; + const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address; + if (normalizedOutput !== normalizedExpected) { + throw new Error(`Expected account ${expectedAccountId}, got ${out0.address}`); + } + } + + const assocAcct = raw.instructionsData?.params?.accountId; + if (assocAcct) { + const normalizedAssoc = Utils.getAddressDetails(assocAcct).address; + const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address; + if (normalizedAssoc !== normalizedExpected) { + throw new Error(`Associate account ${assocAcct} does not match expected ${expectedAccountId}`); + } + } + } + + private validateTokenEnablementTarget( + ex: TransactionExplanation, + raw: HederaRawTransactionData, + expected: { tokenId?: string; tokenName?: string } + ): void { + if (ex.outputs && ex.outputs.length > 0) { + const out0 = ex.outputs[0]; + const explainedName = out0.tokenName; + + if (expected.tokenName) { + if (explainedName !== expected.tokenName) { + throw new Error(`Expected token name ${expected.tokenName}, got ${explainedName}`); + } + } + + if (expected.tokenId && explainedName) { + const actualTokenId = Utils.getHederaTokenIdFromName(explainedName); + if (!actualTokenId) { + throw new Error(`Unable to resolve tokenId for token name ${explainedName}`); + } + if (actualTokenId !== expected.tokenId) { + throw new Error( + `Expected tokenId ${expected.tokenId}, but transaction contains tokenId ${actualTokenId} (${explainedName})` + ); + } + } + } else { + throw new Error('Transaction missing token information in outputs'); + } + + const tokenNames = raw.instructionsData?.params?.tokenNames || []; + if (tokenNames.length !== 1) { + throw new Error(`Expected exactly 1 token to associate, got ${tokenNames.length}`); + } + } + + private validateTxHexAgainstExpected( + txHex: string, + expectedToken: { tokenId?: string; tokenName?: string }, + expectedAccountId: string + ): void { + const transaction = new Transaction(coins.get(this.getChain())); + transaction.fromRawTransaction(txHex); + + const txBody = transaction.txBody; + if (!txBody.tokenAssociate) { + throw new Error('Transaction is not a TokenAssociate transaction'); + } + + const actualAccountId = Utils.stringifyAccountId(txBody.tokenAssociate.account!); + const normalizedActual = Utils.getAddressDetails(actualAccountId).address; + const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address; + if (normalizedActual !== normalizedExpected) { + throw new Error(`TxHex account ${actualAccountId} does not match expected ${expectedAccountId}`); + } + + const actualTokens = txBody.tokenAssociate.tokens || []; + if (actualTokens.length !== 1) { + throw new Error(`TxHex contains ${actualTokens.length} tokens, expected exactly 1`); + } + + const actualTokenId = Utils.stringifyTokenId(actualTokens[0]); + + if (expectedToken.tokenId) { + if (actualTokenId !== expectedToken.tokenId) { + throw new Error(`TxHex tokenId ${actualTokenId} does not match expected ${expectedToken.tokenId}`); + } + } + + if (expectedToken.tokenName) { + const expectedTokenId = Utils.getHederaTokenIdFromName(expectedToken.tokenName); + if (!expectedTokenId) { + throw new Error(`Unable to resolve tokenId for expected token name ${expectedToken.tokenName}`); + } + if (actualTokenId !== expectedTokenId) { + throw new Error( + `TxHex tokenId ${actualTokenId} does not match expected tokenId ${expectedTokenId} for token ${expectedToken.tokenName}` + ); + } + } + } + + private validateAssociateInstructionOnly(raw: HederaRawTransactionData): void { + const instructionType = String(raw.instructionsData?.type || '').toLowerCase(); + + if ( + instructionType === 'contractexecute' || + instructionType === 'contractcall' || + instructionType === 'precompile' + ) { + throw new Error(`Contract-based token association not allowed for blind enablement; got ${instructionType}`); + } + + const isNativeAssociate = + instructionType === 'tokenassociate' || instructionType === 'associate' || instructionType === 'associate_token'; + if (!isNativeAssociate) { + throw new Error( + `Only native TokenAssociate is allowed for blind enablement; got ${instructionType || 'unknown'}` + ); + } + } + async verifyTransaction(params: HbarVerifyTransactionOptions): Promise { // asset name to transfer amount map const coinConfig = coins.get(this.getChain()); - const { txParams: txParams, txPrebuild: txPrebuild, memo: memo } = params; + const { txParams, txPrebuild, memo, verification } = params; const transaction = new Transaction(coinConfig); if (!txPrebuild.txHex) { throw new Error('missing required tx prebuild property txHex'); @@ -245,6 +462,26 @@ export class Hbar extends BaseCoin { throw new Error('missing required tx params property recipients'); } + if (txParams.type === 'enabletoken' && verification?.verifyTokenEnablement) { + const r0 = txParams.recipients[0]; + const expectedToken: { tokenId?: string; tokenName?: string } = {}; + + if (r0.tokenName) { + expectedToken.tokenName = r0.tokenName; + const tokenId = Utils.getHederaTokenIdFromName(r0.tokenName); + if (tokenId) { + expectedToken.tokenId = tokenId; + } + } + + if (!expectedToken.tokenName && !expectedToken.tokenId) { + throw new Error('Token enablement request missing token information'); + } + + await this.verifyTokenEnablementTransaction(txPrebuild.txHex, expectedToken, r0.address); + return true; + } + // for enabletoken, recipient output amount is 0 const recipients = txParams.recipients.map((recipient) => ({ ...recipient, diff --git a/modules/sdk-coin-hbar/src/lib/index.ts b/modules/sdk-coin-hbar/src/lib/index.ts index 9b7a50c0c2..1fc57fda27 100644 --- a/modules/sdk-coin-hbar/src/lib/index.ts +++ b/modules/sdk-coin-hbar/src/lib/index.ts @@ -7,4 +7,5 @@ export { TransferBuilder } from './transferBuilder'; export { CoinTransferBuilder } from './coinTransferBuilder'; export { TokenTransferBuilder } from './tokenTransferBuilder'; export { TokenAssociateBuilder } from './tokenAssociateBuilder'; +export { Recipient } from './iface'; export { Utils }; diff --git a/modules/sdk-coin-hbar/test/unit/hbar.ts b/modules/sdk-coin-hbar/test/unit/hbar.ts index 0b0b3a142a..8350ebb693 100644 --- a/modules/sdk-coin-hbar/test/unit/hbar.ts +++ b/modules/sdk-coin-hbar/test/unit/hbar.ts @@ -1,13 +1,14 @@ import assert from 'assert'; import * as _ from 'lodash'; +import nock from 'nock'; import Sinon, { SinonStub } from 'sinon'; import { randomBytes } from 'crypto'; import { BigNumber } from 'bignumber.js'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; +import { common, Wallet } from '@bitgo/sdk-core'; import { TxData, Transfer } from '../../src/lib/iface'; -import { Wallet } from '@bitgo/sdk-core'; import * as TestData from '../fixtures/hbar'; import { Hbar, Thbar, KeyPair, HbarToken } from '../../src'; @@ -364,6 +365,7 @@ describe('Hedera Hashgraph:', function () { const txParams = newTxParams(); const txPrebuild = newTxPrebuild(); txPrebuild.txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + txParams.type = 'enabletoken'; txParams.recipients = [ { address: '0.0.81320', @@ -376,6 +378,7 @@ describe('Hedera Hashgraph:', function () { txPrebuild, memo, wallet: walletObj, + verification: { verifyTokenEnablement: true }, } as any); validTransaction.should.equal(true); }); @@ -384,6 +387,7 @@ describe('Hedera Hashgraph:', function () { const txParams = newTxParams(); const txPrebuild = newTxPrebuild(); txPrebuild.txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + txParams.type = 'enabletoken'; txParams.recipients = [ { address: '0.0.81321', @@ -392,8 +396,14 @@ describe('Hedera Hashgraph:', function () { }, ]; await basecoin - .verifyTransaction({ txParams, txPrebuild, memo, wallet: walletObj } as any) - .should.be.rejectedWith('Tx outputs does not match with expected txParams recipients'); + .verifyTransaction({ + txParams, + txPrebuild, + memo, + wallet: walletObj, + verification: { verifyTokenEnablement: true }, + } as any) + .should.be.rejectedWith('Invalid token enablement transaction: Expected account 0.0.81321, got 0.0.81320'); }); it('should verify token transfer transaction', async function () { @@ -484,6 +494,7 @@ describe('Hedera Hashgraph:', function () { const txParams = newTxParams(); const txPrebuild = newTxPrebuild(); txPrebuild.txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + txParams.type = 'enabletoken'; txParams.recipients = [ { address: '0.0.81320?memoId=1', @@ -496,11 +507,272 @@ describe('Hedera Hashgraph:', function () { txPrebuild, memo, wallet: walletObj, + verification: { verifyTokenEnablement: true }, } as any); validTransaction.should.equal(true); }); }); + describe('Verify Token Enablement Transaction:', () => { + it('should verify a valid token enablement transaction with tokenName', async function () { + const txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + const expectedToken = { tokenName: 'thbar:usdc' }; + const expectedAccountId = '0.0.81320'; + + await basecoin.verifyTokenEnablementTransaction(txHex, expectedToken, expectedAccountId); + }); + + it('should verify a valid token enablement transaction with tokenId', async function () { + const txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + const expectedToken = { tokenId: '0.0.429274' }; // Actual tokenId for thbar:usdc + const expectedAccountId = '0.0.81320'; + + await basecoin.verifyTokenEnablementTransaction(txHex, expectedToken, expectedAccountId); + }); + + it('should fail when txHex is missing', async function () { + await basecoin + .verifyTokenEnablementTransaction('', { tokenName: 'thbar:usdc' }, '0.0.81320') + .should.be.rejectedWith('Missing required parameters: txHex'); + }); + + it('should fail when expectedAccountId is missing', async function () { + await basecoin + .verifyTokenEnablementTransaction(TestData.UNSIGNED_TOKEN_ASSOCIATE, { tokenName: 'thbar:usdc' }, '') + .should.be.rejectedWith('Missing required parameters: expectedAccountId'); + }); + + it('should fail when wallet platform sends spoofed transaction hex for token enablement', async function () { + // Create a valid transaction response structure from wallet platform with spoofed txHex + // The txHex looks like valid hex but contains malicious/invalid transaction data + const spoofedTxHex = '0a0c0a080800100018a8fb0410130a0c0a080800100018d5d0041014'; // Valid hex but invalid transaction + + // Mock the API endpoints that will be called during token enablement + const bgUrl = common.Environments['mock'].uri; + + // Mock the key endpoint needed for signing + nock(bgUrl) + .post('/api/v2/thbar/key/5b3424f91bf34993006eae94') + .reply(200, [ + { + encryptedPrv: 'fakePrv', + }, + ]); + + // Mock the prebuild API response to return spoofed txHex + nock(bgUrl) + .post('/api/v2/thbar/wallet/5b34252f1bf34993006eae96/tx/build') + .reply(200, { + txHex: spoofedTxHex, + txid: '586c5b59b10b134d04c16ac1b273fe3c5529f34aef75db4456cd469c5cdac7e2', + recipients: [ + { + address: '0.0.81320', + amount: '0', // Valid amount for token enablement + }, + ], + coin: 'thbar', + feeInfo: { + size: 1000, + fee: 1160407, + feeRate: 1160407, + }, + }); + + // This should fail because the spoofed transaction hex contains invalid transaction data + // The verification logic should catch this when trying to validate the transaction + await assert.rejects( + async () => { + // Test the verification directly instead of going through the wallet flow + await basecoin.verifyTokenEnablementTransaction(spoofedTxHex, { tokenName: 'thbar:usdc' }, '0.0.81320'); + }, + (error: any) => { + // The error should indicate that the transaction is invalid + return error.message.includes('Invalid token enablement transaction'); + } + ); + }); + + it('should fail when both tokenId and tokenName are missing', async function () { + await basecoin + .verifyTokenEnablementTransaction(TestData.UNSIGNED_TOKEN_ASSOCIATE, {}, '0.0.81320') + .should.be.rejectedWith('Missing required parameters: expectedToken.tokenId|tokenName'); + }); + + it('should fail when token name does not match', async function () { + await basecoin + .verifyTokenEnablementTransaction( + TestData.UNSIGNED_TOKEN_ASSOCIATE, + { tokenName: 'thbar:wrongtoken' }, + '0.0.81320' + ) + .should.be.rejectedWith(/Expected token name thbar:wrongtoken, got thbar:usdc/); + }); + + it('should fail when account ID does not match', async function () { + await basecoin + .verifyTokenEnablementTransaction(TestData.UNSIGNED_TOKEN_ASSOCIATE, { tokenName: 'thbar:usdc' }, '0.0.99999') + .should.be.rejectedWith(/Expected account 0.0.99999, got 0.0.81320/); + }); + + it('should fail when transaction is not a token enablement (has non-zero amount)', async function () { + // Use a regular transfer transaction which has non-zero amount + await basecoin + .verifyTokenEnablementTransaction(TestData.UNSIGNED_TOKEN_TRANSFER, { tokenName: 'thbar:usdc' }, '0.0.81320') + .should.be.rejectedWith(/Expected output amount '0'/); + }); + + it('should fail when transaction type is not tokenAssociate', async function () { + // Use a regular transfer transaction which is not tokenAssociate + // This will fail on amount validation first, but that's expected behavior + await basecoin + .verifyTokenEnablementTransaction(TestData.UNSIGNED_TOKEN_TRANSFER, { tokenName: 'thbar:usdc' }, '0.0.81320') + .should.be.rejectedWith(/Expected output amount '0'/); + }); + + it('should validate transaction type for valid token associate transaction', async function () { + // This test ensures the transaction type validation works for valid token associate transactions + const txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + const expectedToken = { tokenName: 'thbar:usdc' }; + const expectedAccountId = '0.0.81320'; + + await basecoin.verifyTokenEnablementTransaction(txHex, expectedToken, expectedAccountId); + }); + + it('should fail with invalid transaction hex', async function () { + await basecoin + .verifyTokenEnablementTransaction('invalid_hex', { tokenName: 'thbar:usdc' }, '0.0.81320') + .should.be.rejectedWith(/Invalid token enablement transaction/); + }); + + it('should fail when txHex contains wrong tokenId compared to expected', async function () { + // This test ensures direct txHex validation catches spoofed tokenIds + const txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + const wrongTokenId = '0.0.999999'; // Wrong tokenId + const expectedAccountId = '0.0.81320'; + + await basecoin + .verifyTokenEnablementTransaction(txHex, { tokenId: wrongTokenId }, expectedAccountId) + .should.be.rejectedWith( + new RegExp(`Expected tokenId ${_.escapeRegExp(wrongTokenId)}, but transaction contains tokenId`) + ); + }); + + it('should fail when txHex contains wrong accountId compared to expected', async function () { + // This test ensures direct txHex validation catches spoofed accountIds + const txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + const expectedToken = { tokenName: 'thbar:usdc' }; + const wrongAccountId = '0.0.999999'; // Wrong accountId + + await basecoin + .verifyTokenEnablementTransaction(txHex, expectedToken, wrongAccountId) + .should.be.rejectedWith(new RegExp(`Expected account ${_.escapeRegExp(wrongAccountId)}, got`)); + }); + + it('should pass verification for valid token enablement transaction without throwing error', async function () { + // This test ensures that a valid token enablement transaction passes all validations + const txHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + const expectedToken = { tokenName: 'thbar:usdc' }; + const expectedAccountId = '0.0.81320'; + + // This should complete without throwing any errors + await basecoin.verifyTokenEnablementTransaction(txHex, expectedToken, expectedAccountId); + + // If we reach this point, the test passed + assert.ok(true, 'Valid token enablement transaction should not throw any errors'); + }); + + it('should successfully complete sendTokenEnablements with valid transaction response from wallet platform', async function () { + const validTxHex = TestData.UNSIGNED_TOKEN_ASSOCIATE; + + const wallet = { + id: () => '5b34252f1bf34993006eae96', + coin: () => 'thbar', + prebuildTransaction: async () => ({ + txHex: validTxHex, // Valid token associate transaction + txid: '586c5b59b10b134d04c16ac1b273fe3c5529f34aef75db4456cd469c5cdac7e2', + recipients: [{ address: '0.0.81320', amount: '0' }], + coin: 'thbar', + feeInfo: { size: 1000, fee: 1160407, feeRate: 1160407 }, + }), + sendTokenEnablements: async (params: any) => { + const txPrebuild = await wallet.prebuildTransaction(); + await basecoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: '0.0.81320', amount: '0', tokenName: 'thbar:usdc' }], + }, + txPrebuild, + verification: { verifyTokenEnablement: true }, + }); + + return { + success: [{ txid: txPrebuild.txid, status: 'success' }], + failure: [], + }; + }, + }; + + await wallet.sendTokenEnablements({ + recipients: [{ address: '0.0.81320', tokenName: 'thbar:usdc' }], + }); + + assert.ok( + true, + 'Valid token enablement transaction should complete successfully through sendTokenEnablements flow' + ); + }); + + it('should detect spoofed transaction hex through sendTokenEnablements flow', async function () { + // Use a valid transfer transaction hex instead of a token associate transaction + // This will parse correctly but fail validation because it's not a token associate transaction + const spoofedTxHex = TestData.UNSIGNED_TOKEN_TRANSFER; // Valid transfer transaction, not token associate + + // Create a wallet mock for the sendTokenEnablements flow + const wallet = { + id: () => '5b34252f1bf34993006eae96', + coin: () => 'thbar', + prebuildTransaction: async () => ({ + txHex: spoofedTxHex, + txid: '586c5b59b10b134d04c16ac1b273fe3c5529f34aef75db4456cd469c5cdac7e2', + recipients: [{ address: '0.0.81320', amount: '0' }], + coin: 'thbar', + feeInfo: { size: 1000, fee: 1160407, feeRate: 1160407 }, + }), + sendTokenEnablements: async (params: any) => { + // This should trigger the verification and fail + const txPrebuild = await wallet.prebuildTransaction(); + return basecoin.verifyTransaction({ + txParams: { + type: 'enabletoken', + recipients: [{ address: '0.0.81320', amount: '0', tokenName: 'thbar:usdc' }], + }, + txPrebuild, + verification: { verifyTokenEnablement: true }, + }); + }, + }; + + // This should fail because the spoofed transaction hex contains a transfer transaction, not a token associate + await assert.rejects( + async () => { + await wallet.sendTokenEnablements({ + recipients: [{ address: '0.0.81320', tokenName: 'thbar:usdc' }], + }); + }, + (error: any) => { + // The error should indicate that the transaction is invalid for token enablement + return ( + error.message.includes('Invalid token enablement transaction') || + error.message.includes('Token enablement transaction missing tokenId') || + error.message.includes("Expected output amount '0'") || + error.message.includes('Transaction is not a TokenAssociate transaction') + ); + } + ); + }); + }); + describe('Sign Message', () => { it('should be performed', async () => { const keyPair = new KeyPair();