Skip to content

Commit b638a30

Browse files
committed
refactor(sdk-coin-hbar): enhance token enablement verification
Improve validation with stricter txHex checks and security enhancements. WP-5746 TICKET: WP-5746
1 parent 43e27d0 commit b638a30

File tree

2 files changed

+198
-89
lines changed

2 files changed

+198
-89
lines changed

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

Lines changed: 98 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -40,27 +40,19 @@ import {
4040
} from '@hashgraph/sdk';
4141
import { PUBLIC_KEY_PREFIX } from './lib/keyPair';
4242

43-
// Extended transaction data interface for raw transaction validation
44-
interface RawTransactionData {
43+
// Hedera-specific transaction data interface for raw transaction validation
44+
interface HederaRawTransactionData {
4545
id?: string;
46-
hash?: string;
4746
from?: string;
48-
data?: string;
4947
fee?: number;
5048
startTime?: string;
5149
validDuration?: string;
5250
node?: string;
5351
memo?: string;
54-
to?: string;
5552
amount?: string;
56-
accountId?: string;
5753
instructionsData?: {
5854
type?: string;
5955
accountId?: string;
60-
owner?: string;
61-
tokens?: string[];
62-
tokenIds?: string[];
63-
tokenId?: string;
6456
params?: {
6557
accountId?: string;
6658
tokenNames?: string[];
@@ -71,9 +63,6 @@ interface RawTransactionData {
7163
}>;
7264
};
7365
};
74-
instructions?: unknown[];
75-
innerInstructions?: unknown[];
76-
scheduledTransactionBody?: unknown;
7766
}
7867
export interface HbarSignTransactionOptions extends SignTransactionOptions {
7968
txPrebuild: TransactionPrebuild;
@@ -272,7 +261,6 @@ export class Hbar extends BaseCoin {
272261
expectedToken: { tokenId?: string; tokenName?: string },
273262
expectedAccountId: string
274263
): Promise<void> {
275-
// Validate required parameters
276264
if (!txHex || !expectedAccountId || (!expectedToken.tokenId && !expectedToken.tokenName)) {
277265
const missing: string[] = [];
278266
if (!txHex) missing.push('txHex');
@@ -282,26 +270,23 @@ export class Hbar extends BaseCoin {
282270
}
283271

284272
try {
285-
// Parse and explain the transaction
286-
const explainedTx = await this.explainTransaction({ txHex });
287-
288-
// Parse transaction from hex for validation
289273
const transaction = new Transaction(coins.get(this.getChain()));
290274
transaction.fromRawTransaction(txHex);
291275
const raw = transaction.toJson();
292276

293-
// Validate all aspects of the token enablement transaction with strict checks
277+
const explainedTx = await this.explainTransaction({ txHex });
278+
294279
this.validateTxStructureStrict(explainedTx);
295280
this.validateNoTransfers(raw);
296281
this.validateAccountIdMatches(explainedTx, raw, expectedAccountId);
297282
this.validateTokenEnablementTarget(explainedTx, raw, expectedToken);
298283
this.validateAssociateInstructionOnly(raw);
284+
this.validateTxHexAgainstExpected(txHex, expectedToken, expectedAccountId);
299285
} catch (error) {
300286
throw new Error(`Invalid token enablement transaction: ${error.message}`);
301287
}
302288
}
303289

304-
// Strict validation: allow exactly 1 output with amount 0
305290
private validateTxStructureStrict(ex: TransactionExplanation): void {
306291
if (!ex.outputs || ex.outputs.length === 0) {
307292
throw new Error('Invalid token enablement transaction: missing required token association output');
@@ -315,11 +300,8 @@ export class Hbar extends BaseCoin {
315300
}
316301
}
317302

318-
// Simple validation to ensure no transfers are present in token enablement transaction
319-
private validateNoTransfers(raw: RawTransactionData): void {
320-
// Check for transfers in the instructionsData recipients
303+
private validateNoTransfers(raw: HederaRawTransactionData): void {
321304
if (raw.instructionsData?.params?.recipients?.length && raw.instructionsData.params.recipients.length > 0) {
322-
// Allow recipients with amount '0' for token enablement, but reject non-zero amounts
323305
const hasNonZeroTransfers = raw.instructionsData.params.recipients.some(
324306
(recipient: any) => recipient.amount && recipient.amount !== '0'
325307
);
@@ -328,104 +310,134 @@ export class Hbar extends BaseCoin {
328310
}
329311
}
330312

331-
// Check for direct transfer amount (should be '0' or undefined for token enablement)
332313
if (raw.amount && raw.amount !== '0') {
333314
throw new Error('Transaction contains transfers; not a pure token enablement.');
334315
}
335316
}
336317

337-
// Validate account ID matches in both explained and raw transaction data with normalization
338318
private validateAccountIdMatches(
339319
ex: TransactionExplanation,
340-
raw: RawTransactionData,
320+
raw: HederaRawTransactionData,
341321
expectedAccountId: string
342322
): void {
343-
// Only validate if outputs exist
344323
if (ex.outputs && ex.outputs.length > 0) {
345324
const out0 = ex.outputs[0];
346-
if (!Utils.isSameBaseAddress(out0.address, expectedAccountId)) {
325+
const normalizedOutput = Utils.getAddressDetails(out0.address).address;
326+
const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address;
327+
if (normalizedOutput !== normalizedExpected) {
347328
throw new Error(`Expected account ${expectedAccountId}, got ${out0.address}`);
348329
}
349330
}
350331

351-
const assocAcct = raw.instructionsData?.accountId ?? raw.instructionsData?.owner ?? raw.accountId;
352-
if (assocAcct && !Utils.isSameBaseAddress(assocAcct, expectedAccountId)) {
353-
throw new Error(`Associate account ${assocAcct} does not match expected ${expectedAccountId}`);
332+
const assocAcct = raw.instructionsData?.params?.accountId;
333+
if (assocAcct) {
334+
const normalizedAssoc = Utils.getAddressDetails(assocAcct).address;
335+
const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address;
336+
if (normalizedAssoc !== normalizedExpected) {
337+
throw new Error(`Associate account ${assocAcct} does not match expected ${expectedAccountId}`);
338+
}
354339
}
355340
}
356341

357-
// Validate token enablement target with preference for tokenId over tokenName
358342
private validateTokenEnablementTarget(
359343
ex: TransactionExplanation,
360-
raw: RawTransactionData,
344+
raw: HederaRawTransactionData,
361345
expected: { tokenId?: string; tokenName?: string }
362346
): void {
363-
// Get tokens from raw transaction data
364-
const rawTokens: string[] =
365-
raw.instructionsData?.tokens ??
366-
raw.instructionsData?.tokenIds ??
367-
(raw.instructionsData?.tokenId ? [raw.instructionsData.tokenId] : []);
368-
369-
// If we have raw token data, validate it strictly for security
370-
if (rawTokens.length > 0) {
371-
// Must have exactly 1 token to associate
372-
if (rawTokens.length !== 1) {
373-
throw new Error(`Expected exactly 1 token to associate, got ${rawTokens.length}`);
347+
if (ex.outputs && ex.outputs.length > 0) {
348+
const out0 = ex.outputs[0];
349+
const explainedName = out0.tokenName;
350+
351+
if (expected.tokenName) {
352+
if (explainedName !== expected.tokenName) {
353+
throw new Error(`Expected token name ${expected.tokenName}, got ${explainedName}`);
354+
}
374355
}
375356

376-
// Prefer tokenId validation over tokenName
377-
if (expected.tokenId) {
378-
if (rawTokens[0] !== expected.tokenId) {
379-
throw new Error(`Raw tokenId ${rawTokens[0]} != expected ${expected.tokenId}`);
357+
if (expected.tokenId && explainedName) {
358+
const actualTokenId = Utils.getHederaTokenIdFromName(explainedName);
359+
if (!actualTokenId) {
360+
throw new Error(`Unable to resolve tokenId for token name ${explainedName}`);
361+
}
362+
if (actualTokenId !== expected.tokenId) {
363+
throw new Error(
364+
`Expected tokenId ${expected.tokenId}, but transaction contains tokenId ${actualTokenId} (${explainedName})`
365+
);
380366
}
381367
}
368+
} else {
369+
throw new Error('Transaction missing token information in outputs');
382370
}
383371

384-
// Primary validation: tokenName from explained transaction
385-
if (expected.tokenName && ex.outputs && ex.outputs.length > 0) {
386-
const out0 = ex.outputs[0];
387-
const explainedName = out0.tokenName;
388-
if (explainedName !== expected.tokenName) {
389-
throw new Error(`Expected token name ${expected.tokenName}, got ${explainedName}`);
372+
const tokenNames = raw.instructionsData?.params?.tokenNames || [];
373+
if (tokenNames.length !== 1) {
374+
throw new Error(`Expected exactly 1 token to associate, got ${tokenNames.length}`);
375+
}
376+
}
377+
378+
private validateTxHexAgainstExpected(
379+
txHex: string,
380+
expectedToken: { tokenId?: string; tokenName?: string },
381+
expectedAccountId: string
382+
): void {
383+
const transaction = new Transaction(coins.get(this.getChain()));
384+
transaction.fromRawTransaction(txHex);
385+
386+
const txBody = transaction.txBody;
387+
if (!txBody.tokenAssociate) {
388+
throw new Error('Transaction is not a TokenAssociate transaction');
389+
}
390+
391+
const actualAccountId = Utils.stringifyAccountId(txBody.tokenAssociate.account!);
392+
const normalizedActual = Utils.getAddressDetails(actualAccountId).address;
393+
const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address;
394+
if (normalizedActual !== normalizedExpected) {
395+
throw new Error(`TxHex account ${actualAccountId} does not match expected ${expectedAccountId}`);
396+
}
397+
398+
const actualTokens = txBody.tokenAssociate.tokens || [];
399+
if (actualTokens.length !== 1) {
400+
throw new Error(`TxHex contains ${actualTokens.length} tokens, expected exactly 1`);
401+
}
402+
403+
const actualTokenId = Utils.stringifyTokenId(actualTokens[0]);
404+
405+
if (expectedToken.tokenId) {
406+
if (actualTokenId !== expectedToken.tokenId) {
407+
throw new Error(`TxHex tokenId ${actualTokenId} does not match expected ${expectedToken.tokenId}`);
408+
}
409+
}
410+
411+
if (expectedToken.tokenName) {
412+
const expectedTokenId = Utils.getHederaTokenIdFromName(expectedToken.tokenName);
413+
if (!expectedTokenId) {
414+
throw new Error(`Unable to resolve tokenId for expected token name ${expectedToken.tokenName}`);
415+
}
416+
if (actualTokenId !== expectedTokenId) {
417+
throw new Error(
418+
`TxHex tokenId ${actualTokenId} does not match expected tokenId ${expectedTokenId} for token ${expectedToken.tokenName}`
419+
);
390420
}
391421
}
392422
}
393423

394-
// Validate that this is a pure native token associate instruction with no additional operations
395-
private validateAssociateInstructionOnly(raw: RawTransactionData): void {
424+
private validateAssociateInstructionOnly(raw: HederaRawTransactionData): void {
396425
const t = String(raw.instructionsData?.type || '').toLowerCase();
397426

398-
// Explicitly reject ContractExecute/precompile routes first
399427
if (t === 'contractexecute' || t === 'contractcall' || t === 'precompile') {
400428
throw new Error(`Contract-based token association not allowed for blind enablement; got ${t}`);
401429
}
402430

403-
// Only allow native TokenAssociate
404431
const isNativeAssociate = t === 'tokenassociate' || t === 'associate' || t === 'associate_token';
405432
if (!isNativeAssociate) {
406433
throw new Error(`Only native TokenAssociate is allowed for blind enablement; got ${t || 'unknown'}`);
407434
}
408-
409-
// Strict batching validation - no additional instructions allowed
410-
if (Array.isArray(raw.instructions) && raw.instructions.length > 0) {
411-
throw new Error('Additional instructions found; transaction is not a pure token enablement.');
412-
}
413-
if (Array.isArray(raw.innerInstructions) && raw.innerInstructions.length > 0) {
414-
throw new Error('Inner instructions found; transaction is not a pure token enablement.');
415-
}
416-
if (raw.scheduledTransactionBody) {
417-
throw new Error('Scheduled transactions are not allowed in blind enablement.');
418-
}
419435
}
420436

421-
async verifyTransaction({
422-
txParams: txParams,
423-
txPrebuild: txPrebuild,
424-
memo: memo,
425-
verification,
426-
}: HbarVerifyTransactionOptions): Promise<boolean> {
437+
async verifyTransaction(params: HbarVerifyTransactionOptions): Promise<boolean> {
427438
// asset name to transfer amount map
428439
const coinConfig = coins.get(this.getChain());
440+
const { txParams, txPrebuild, memo, verification } = params;
429441
const transaction = new Transaction(coinConfig);
430442
if (!txPrebuild.txHex) {
431443
throw new Error('missing required tx prebuild property txHex');
@@ -443,24 +455,24 @@ export class Hbar extends BaseCoin {
443455
throw new Error('missing required tx params property recipients');
444456
}
445457

446-
// for enabletoken, use verifyTokenEnablementTransaction and return immediately
447458
if (txParams.type === 'enabletoken' && verification?.verifyTokenEnablement) {
448459
const r0 = txParams.recipients[0];
449460
const expectedToken: { tokenId?: string; tokenName?: string } = {};
450461

451-
// Extract tokenId from transaction
452-
const transaction = new Transaction(coins.get(this.getChain()));
453-
transaction.fromRawTransaction(txPrebuild.txHex);
454-
const tokenIds = transaction.txBody.tokenAssociate?.tokens || [];
462+
if (r0.tokenName) {
463+
expectedToken.tokenName = r0.tokenName;
464+
const tokenId = Utils.getHederaTokenIdFromName(r0.tokenName);
465+
if (tokenId) {
466+
expectedToken.tokenId = tokenId;
467+
}
468+
}
455469

456-
if (tokenIds.length > 0) {
457-
expectedToken.tokenId = Utils.stringifyTokenId(tokenIds[0]);
458-
} else {
459-
throw new Error('Token enablement transaction missing tokenId');
470+
if (!expectedToken.tokenName && !expectedToken.tokenId) {
471+
throw new Error('Token enablement request missing token information');
460472
}
461473

462474
await this.verifyTokenEnablementTransaction(txPrebuild.txHex, expectedToken, r0.address);
463-
return true; // IMPORTANT: do not fall through to generic transfer verification
475+
return true;
464476
}
465477

466478
// for enabletoken, recipient output amount is 0

0 commit comments

Comments
 (0)