Skip to content

Commit e4078d4

Browse files
authored
Merge pull request #7003 from BitGo/WP-5746/hbar-verify-token-enablement-tx
feat(sdk-coin-hbar): add token enablement transaction verification
2 parents 08b17d6 + 2136fff commit e4078d4

File tree

3 files changed

+515
-5
lines changed

3 files changed

+515
-5
lines changed

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

Lines changed: 239 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
import { BigNumber } from 'bignumber.js';
2929
import * as stellar from 'stellar-sdk';
3030
import { SeedValidator } from './seedValidator';
31-
import { KeyPair as HbarKeyPair, TransactionBuilderFactory, Transaction } from './lib';
31+
import { KeyPair as HbarKeyPair, TransactionBuilderFactory, Transaction, Recipient as HederaRecipient } from './lib';
3232
import * as Utils from './lib/utils';
3333
import * as _ from 'lodash';
3434
import {
@@ -39,6 +39,31 @@ import {
3939
Hbar as HbarUnit,
4040
} from '@hashgraph/sdk';
4141
import { PUBLIC_KEY_PREFIX } from './lib/keyPair';
42+
43+
// Hedera-specific transaction data interface for raw transaction validation
44+
interface HederaRawTransactionData {
45+
id?: string;
46+
from?: string;
47+
fee?: number;
48+
startTime?: string;
49+
validDuration?: string;
50+
node?: string;
51+
memo?: string;
52+
amount?: string;
53+
instructionsData?: {
54+
type?: string;
55+
accountId?: string;
56+
params?: {
57+
accountId?: string;
58+
tokenNames?: string[];
59+
recipients?: Array<{
60+
address: string;
61+
amount: string;
62+
tokenName?: string;
63+
}>;
64+
};
65+
};
66+
}
4267
export interface HbarSignTransactionOptions extends SignTransactionOptions {
4368
txPrebuild: TransactionPrebuild;
4469
prv: string;
@@ -224,10 +249,202 @@ export class Hbar extends BaseCoin {
224249
return Utils.isSameBaseAddress(address, baseAddress);
225250
}
226251

252+
/**
253+
* Verify a token enablement transaction with strict validation
254+
* @param txHex - The transaction hex to verify
255+
* @param expectedToken - Object containing tokenId (preferred) or tokenName
256+
* @param expectedAccountId - The expected account ID that will enable the token
257+
* @throws Error if the transaction is not a valid token enablement transaction
258+
*/
259+
async verifyTokenEnablementTransaction(
260+
txHex: string,
261+
expectedToken: { tokenId?: string; tokenName?: string },
262+
expectedAccountId: string
263+
): Promise<void> {
264+
if (!txHex || !expectedAccountId || (!expectedToken.tokenId && !expectedToken.tokenName)) {
265+
const missing: string[] = [];
266+
if (!txHex) missing.push('txHex');
267+
if (!expectedAccountId) missing.push('expectedAccountId');
268+
if (!expectedToken.tokenId && !expectedToken.tokenName) missing.push('expectedToken.tokenId|tokenName');
269+
throw new Error(`Missing required parameters: ${missing.join(', ')}`);
270+
}
271+
272+
try {
273+
const transaction = new Transaction(coins.get(this.getChain()));
274+
transaction.fromRawTransaction(txHex);
275+
const raw = transaction.toJson();
276+
277+
const explainedTx = await this.explainTransaction({ txHex });
278+
279+
this.validateTxStructureStrict(explainedTx);
280+
this.validateNoTransfers(raw);
281+
this.validateAccountIdMatches(explainedTx, raw, expectedAccountId);
282+
this.validateTokenEnablementTarget(explainedTx, raw, expectedToken);
283+
this.validateAssociateInstructionOnly(raw);
284+
this.validateTxHexAgainstExpected(txHex, expectedToken, expectedAccountId);
285+
} catch (error) {
286+
throw new Error(`Invalid token enablement transaction: ${error.message}`);
287+
}
288+
}
289+
290+
private validateTxStructureStrict(ex: TransactionExplanation): void {
291+
if (!ex.outputs || ex.outputs.length === 0) {
292+
throw new Error('Invalid token enablement transaction: missing required token association output');
293+
}
294+
if (ex.outputs.length !== 1) {
295+
throw new Error(`Expected exactly 1 output, got ${ex.outputs.length}`);
296+
}
297+
const out0 = ex.outputs[0];
298+
if (out0.amount !== '0') {
299+
throw new Error(`Expected output amount '0', got ${out0.amount}`);
300+
}
301+
}
302+
303+
private validateNoTransfers(raw: HederaRawTransactionData): void {
304+
if (raw.instructionsData?.params?.recipients?.length && raw.instructionsData.params.recipients.length > 0) {
305+
const hasNonZeroTransfers = raw.instructionsData.params.recipients.some(
306+
(recipient: HederaRecipient) => recipient.amount && recipient.amount !== '0'
307+
);
308+
if (hasNonZeroTransfers) {
309+
throw new Error('Transaction contains transfers; not a pure token enablement.');
310+
}
311+
}
312+
313+
if (raw.amount && raw.amount !== '0') {
314+
throw new Error('Transaction contains transfers; not a pure token enablement.');
315+
}
316+
}
317+
318+
private validateAccountIdMatches(
319+
ex: TransactionExplanation,
320+
raw: HederaRawTransactionData,
321+
expectedAccountId: string
322+
): void {
323+
if (ex.outputs && ex.outputs.length > 0) {
324+
const out0 = ex.outputs[0];
325+
const normalizedOutput = Utils.getAddressDetails(out0.address).address;
326+
const normalizedExpected = Utils.getAddressDetails(expectedAccountId).address;
327+
if (normalizedOutput !== normalizedExpected) {
328+
throw new Error(`Expected account ${expectedAccountId}, got ${out0.address}`);
329+
}
330+
}
331+
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+
}
339+
}
340+
}
341+
342+
private validateTokenEnablementTarget(
343+
ex: TransactionExplanation,
344+
raw: HederaRawTransactionData,
345+
expected: { tokenId?: string; tokenName?: string }
346+
): void {
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+
}
355+
}
356+
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+
);
366+
}
367+
}
368+
} else {
369+
throw new Error('Transaction missing token information in outputs');
370+
}
371+
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+
);
420+
}
421+
}
422+
}
423+
424+
private validateAssociateInstructionOnly(raw: HederaRawTransactionData): void {
425+
const instructionType = String(raw.instructionsData?.type || '').toLowerCase();
426+
427+
if (
428+
instructionType === 'contractexecute' ||
429+
instructionType === 'contractcall' ||
430+
instructionType === 'precompile'
431+
) {
432+
throw new Error(`Contract-based token association not allowed for blind enablement; got ${instructionType}`);
433+
}
434+
435+
const isNativeAssociate =
436+
instructionType === 'tokenassociate' || instructionType === 'associate' || instructionType === 'associate_token';
437+
if (!isNativeAssociate) {
438+
throw new Error(
439+
`Only native TokenAssociate is allowed for blind enablement; got ${instructionType || 'unknown'}`
440+
);
441+
}
442+
}
443+
227444
async verifyTransaction(params: HbarVerifyTransactionOptions): Promise<boolean> {
228445
// asset name to transfer amount map
229446
const coinConfig = coins.get(this.getChain());
230-
const { txParams: txParams, txPrebuild: txPrebuild, memo: memo } = params;
447+
const { txParams, txPrebuild, memo, verification } = params;
231448
const transaction = new Transaction(coinConfig);
232449
if (!txPrebuild.txHex) {
233450
throw new Error('missing required tx prebuild property txHex');
@@ -245,6 +462,26 @@ export class Hbar extends BaseCoin {
245462
throw new Error('missing required tx params property recipients');
246463
}
247464

465+
if (txParams.type === 'enabletoken' && verification?.verifyTokenEnablement) {
466+
const r0 = txParams.recipients[0];
467+
const expectedToken: { tokenId?: string; tokenName?: string } = {};
468+
469+
if (r0.tokenName) {
470+
expectedToken.tokenName = r0.tokenName;
471+
const tokenId = Utils.getHederaTokenIdFromName(r0.tokenName);
472+
if (tokenId) {
473+
expectedToken.tokenId = tokenId;
474+
}
475+
}
476+
477+
if (!expectedToken.tokenName && !expectedToken.tokenId) {
478+
throw new Error('Token enablement request missing token information');
479+
}
480+
481+
await this.verifyTokenEnablementTransaction(txPrebuild.txHex, expectedToken, r0.address);
482+
return true;
483+
}
484+
248485
// for enabletoken, recipient output amount is 0
249486
const recipients = txParams.recipients.map((recipient) => ({
250487
...recipient,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export { TransferBuilder } from './transferBuilder';
77
export { CoinTransferBuilder } from './coinTransferBuilder';
88
export { TokenTransferBuilder } from './tokenTransferBuilder';
99
export { TokenAssociateBuilder } from './tokenAssociateBuilder';
10+
export { Recipient } from './iface';
1011
export { Utils };

0 commit comments

Comments
 (0)