@@ -28,7 +28,7 @@ import {
2828import { BigNumber } from 'bignumber.js' ;
2929import * as stellar from 'stellar-sdk' ;
3030import { SeedValidator } from './seedValidator' ;
31- import { KeyPair as HbarKeyPair , TransactionBuilderFactory , Transaction } from './lib' ;
31+ import { KeyPair as HbarKeyPair , TransactionBuilderFactory , Transaction , Recipient as HederaRecipient } from './lib' ;
3232import * as Utils from './lib/utils' ;
3333import * as _ from 'lodash' ;
3434import {
@@ -39,6 +39,31 @@ import {
3939 Hbar as HbarUnit ,
4040} from '@hashgraph/sdk' ;
4141import { 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+ }
4267export 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 ,
0 commit comments