From 8d09223f277152e75ab7cb59ec6eeeef6a033da5 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Wed, 10 Dec 2025 15:00:36 +0530 Subject: [PATCH] feat(sdk-coin-flrp): add transaction validation utility Ticket: WIN-8240 --- modules/sdk-coin-flrp/src/lib/transaction.ts | 8 + modules/sdk-coin-flrp/src/lib/utils.ts | 41 ++++- modules/sdk-coin-flrp/test/unit/lib/utils.ts | 163 +++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index b34612b2eb..c57920a681 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -261,6 +261,14 @@ export class Transaction extends BaseTransaction { this._flareTransaction = tx as UnsignedTx; } + /** + * Get the underlying Flare transaction + * @returns The Flare transaction object + */ + getFlareTransaction(): Tx { + return this._flareTransaction; + } + setTransactionType(transactionType: TransactionType): void { if (![TransactionType.AddPermissionlessValidator].includes(transactionType)) { throw new Error(`Transaction type ${transactionType} is not supported`); diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index efc94f2298..3d374d0762 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -11,7 +11,7 @@ import { FlareNetwork } from '@bitgo/statics'; import { Buffer } from 'buffer'; import { createHash } from 'crypto'; import { ecc } from '@bitgo/secp256k1'; -import { ADDRESS_SEPARATOR, Output } from './iface'; +import { ADDRESS_SEPARATOR, Output, Tx } from './iface'; import bs58 from 'bs58'; import { bech32 } from 'bech32'; @@ -390,6 +390,45 @@ export class Utils implements BaseUtils { throw new Error(`Failed to recover signature: ${error.message}`); } } + + /** + * Check if tx is for the blockchainId + * + * @param {Tx} tx + * @param {string} blockchainId - blockchain ID in hex format + * @returns true if tx is for blockchainId + */ + isTransactionOf(tx: Tx, blockchainId: string): boolean { + // Note: getBlockchainId() and BlockchainId.value() return CB58-encoded strings, + // but we need hex format, so we use toBytes() and convert to hex + const extractBlockchainId = (txObj: any): string | null => { + if (typeof txObj.getTx === 'function') { + const innerTx = txObj.getTx(); + if (innerTx.baseTx?.BlockchainId?.toBytes) { + return Buffer.from(innerTx.baseTx.BlockchainId.toBytes()).toString('hex'); + } + if (innerTx.blockchainId?.toBytes) { + return Buffer.from(innerTx.blockchainId.toBytes()).toString('hex'); + } + } + + if (txObj.tx?.baseTx?.BlockchainId?.toBytes) { + return Buffer.from(txObj.tx.baseTx.BlockchainId.toBytes()).toString('hex'); + } + + if (txObj.baseTx?.BlockchainId?.toBytes) { + return Buffer.from(txObj.baseTx.BlockchainId.toBytes()).toString('hex'); + } + if (txObj.blockchainId?.toBytes) { + return Buffer.from(txObj.blockchainId.toBytes()).toString('hex'); + } + + return null; + }; + + const txBlockchainId = extractBlockchainId(tx); + return txBlockchainId === blockchainId; + } } const utils = new Utils(); diff --git a/modules/sdk-coin-flrp/test/unit/lib/utils.ts b/modules/sdk-coin-flrp/test/unit/lib/utils.ts index d302be6815..b234dff6cd 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/utils.ts @@ -12,6 +12,9 @@ import { import { ecc } from '@bitgo/secp256k1'; import { EXPORT_IN_C } from '../../resources/transactionData/exportInC'; import { IMPORT_IN_P } from '../../resources/transactionData/importInP'; +import { EXPORT_IN_P } from '../../resources/transactionData/exportInP'; +import { IMPORT_IN_C } from '../../resources/transactionData/importInC'; +import { TransactionBuilderFactory, Transaction } from '../../../src/lib'; describe('Utils', function () { let utils: Utils; @@ -539,4 +542,164 @@ describe('Utils', function () { assert.throws(() => utils.recoverySignature(network, message, signature), /Failed to recover signature/); }); }); + + describe('isTransactionOf', function () { + const factory = new TransactionBuilderFactory(coins.get('tflrp')); + const utilsInstance = new Utils(); + const testnetNetwork = coins.get('tflrp').network as FlareNetwork; + const pChainBlockchainIdHex = Buffer.from(utilsInstance.cb58Decode(testnetNetwork.blockchainID)).toString('hex'); + const cChainBlockchainIdHex = Buffer.from(utilsInstance.cb58Decode(testnetNetwork.cChainBlockchainID)).toString( + 'hex' + ); + + it('should return true for Import in P transaction with matching P-chain blockchain ID', async function () { + const txBuilder = factory + .getImportInPBuilder() + .threshold(IMPORT_IN_P.threshold) + .locktime(IMPORT_IN_P.locktime) + .fromPubKey(IMPORT_IN_P.pAddresses) + .externalChainId(IMPORT_IN_P.sourceChainId) + .fee(IMPORT_IN_P.fee) + .utxos(IMPORT_IN_P.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, pChainBlockchainIdHex), true); + }); + + it('should return false for Import in P transaction with non-matching C-chain blockchain ID', async function () { + const txBuilder = factory + .getImportInPBuilder() + .threshold(IMPORT_IN_P.threshold) + .locktime(IMPORT_IN_P.locktime) + .fromPubKey(IMPORT_IN_P.pAddresses) + .externalChainId(IMPORT_IN_P.sourceChainId) + .fee(IMPORT_IN_P.fee) + .utxos(IMPORT_IN_P.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, cChainBlockchainIdHex), false); + }); + + it('should return true for Export in P transaction with matching P-chain blockchain ID', async function () { + const txBuilder = factory + .getExportInPBuilder() + .threshold(EXPORT_IN_P.threshold) + .locktime(EXPORT_IN_P.locktime) + .fromPubKey(EXPORT_IN_P.pAddresses) + .externalChainId(EXPORT_IN_P.sourceChainId) + .fee(EXPORT_IN_P.fee) + .amount(EXPORT_IN_P.amount) + .utxos(EXPORT_IN_P.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, pChainBlockchainIdHex), true); + }); + + it('should return false for Export in P transaction with non-matching C-chain blockchain ID', async function () { + const txBuilder = factory + .getExportInPBuilder() + .threshold(EXPORT_IN_P.threshold) + .locktime(EXPORT_IN_P.locktime) + .fromPubKey(EXPORT_IN_P.pAddresses) + .externalChainId(EXPORT_IN_P.sourceChainId) + .fee(EXPORT_IN_P.fee) + .amount(EXPORT_IN_P.amount) + .utxos(EXPORT_IN_P.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, cChainBlockchainIdHex), false); + }); + + it('should return true for Import in C transaction with matching C-chain blockchain ID', async function () { + const txBuilder = factory + .getImportInCBuilder() + .threshold(IMPORT_IN_C.threshold) + .locktime(IMPORT_IN_C.locktime) + .fromPubKey(IMPORT_IN_C.pAddresses) + .externalChainId(IMPORT_IN_C.sourceChainId) + .feeRate(IMPORT_IN_C.fee) + .to(IMPORT_IN_C.to) + .utxos(IMPORT_IN_C.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, cChainBlockchainIdHex), true); + }); + + it('should return false for Import in C transaction with non-matching P-chain blockchain ID', async function () { + const txBuilder = factory + .getImportInCBuilder() + .threshold(IMPORT_IN_C.threshold) + .locktime(IMPORT_IN_C.locktime) + .fromPubKey(IMPORT_IN_C.pAddresses) + .externalChainId(IMPORT_IN_C.sourceChainId) + .feeRate(IMPORT_IN_C.fee) + .to(IMPORT_IN_C.to) + .utxos(IMPORT_IN_C.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, pChainBlockchainIdHex), false); + }); + + it('should return true for Export in C transaction with matching C-chain blockchain ID', async function () { + const txBuilder = factory + .getExportInCBuilder() + .fromPubKey(EXPORT_IN_C.cHexAddress) + .nonce(EXPORT_IN_C.nonce) + .amount(EXPORT_IN_C.amount) + .threshold(EXPORT_IN_C.threshold) + .locktime(EXPORT_IN_C.locktime) + .to(EXPORT_IN_C.pAddresses) + .feeRate(EXPORT_IN_C.fee); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, cChainBlockchainIdHex), true); + }); + + it('should return false for Export in C transaction with non-matching P-chain blockchain ID', async function () { + const txBuilder = factory + .getExportInCBuilder() + .fromPubKey(EXPORT_IN_C.cHexAddress) + .nonce(EXPORT_IN_C.nonce) + .amount(EXPORT_IN_C.amount) + .threshold(EXPORT_IN_C.threshold) + .locktime(EXPORT_IN_C.locktime) + .to(EXPORT_IN_C.pAddresses) + .feeRate(EXPORT_IN_C.fee); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, pChainBlockchainIdHex), false); + }); + + it('should return false for invalid blockchain ID', async function () { + const txBuilder = factory + .getImportInPBuilder() + .threshold(IMPORT_IN_P.threshold) + .locktime(IMPORT_IN_P.locktime) + .fromPubKey(IMPORT_IN_P.pAddresses) + .externalChainId(IMPORT_IN_P.sourceChainId) + .fee(IMPORT_IN_P.fee) + .utxos(IMPORT_IN_P.outputs); + + const tx = (await txBuilder.build()) as Transaction; + const flareTransaction = tx.getFlareTransaction(); + + assert.strictEqual(utilsInstance.isTransactionOf(flareTransaction, 'invalidblockchainid'), false); + }); + }); });