From 074b8f949b3dfdcc1e1104cbc891c374afec9a8f Mon Sep 17 00:00:00 2001 From: Alla Gopi Karath Date: Tue, 20 Jan 2026 12:32:08 +0530 Subject: [PATCH] fix(sdk-coin-ada): token verifytransaction TICKET: COIN-7292 --- modules/sdk-coin-ada/src/adaToken.ts | 67 +++- .../sdk-coin-ada/test/unit/tokenWithdrawal.ts | 302 +++++++++++++++++- 2 files changed, 367 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-ada/src/adaToken.ts b/modules/sdk-coin-ada/src/adaToken.ts index f341c82c14..1f1279791b 100644 --- a/modules/sdk-coin-ada/src/adaToken.ts +++ b/modules/sdk-coin-ada/src/adaToken.ts @@ -1,6 +1,15 @@ import { Ada } from './ada'; -import { BitGoBase, CoinConstructor, NamedCoinConstructor } from '@bitgo/sdk-core'; +import { + BitGoBase, + CoinConstructor, + NamedCoinConstructor, + VerifyTransactionOptions, + NodeEnvironmentError, +} from '@bitgo/sdk-core'; import { coins, tokens, AdaTokenConfig } from '@bitgo/statics'; +import { Transaction } from './lib'; +import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs'; +import assert from 'assert'; export class AdaToken extends Ada { public readonly tokenConfig: AdaTokenConfig; @@ -85,4 +94,60 @@ export class AdaToken extends Ada { get contractAddress() { return this.tokenConfig.contractAddress; } + + /** + * Verify that a token transaction prebuild complies with the original intention. + * For token transfers, we need to verify the token amount in multiAssets, not the ADA amount. + * + * @param params.txPrebuild prebuild transaction + * @param params.txParams transaction parameters + * @return true if verification success + */ + async verifyTransaction(params: VerifyTransactionOptions): Promise { + try { + const coinConfig = coins.get(this.getBaseChain()); + const { txPrebuild, txParams } = params; + const transaction = new Transaction(coinConfig); + assert(txPrebuild.txHex, new Error('missing required tx prebuild property txHex')); + + transaction.fromRawTransaction(txPrebuild.txHex); + const txJson = transaction.toJson(); + + if (txParams.recipients !== undefined) { + // assetName in tokenConfig is ASCII (e.g. 'WATER'), convert to hex for comparison + const asciiEncodedAssetName = Buffer.from(this.tokenConfig.assetName).toString('hex'); + + // ASCII encoded asset name may be appended to the policy ID (consistent with crypto compare) + // But cardano sdk requires only the policy ID (28 bytes = 56 hex chars) for ScriptHash + let policyId = this.tokenConfig.policyId; + if (policyId.endsWith(asciiEncodedAssetName)) { + policyId = policyId.substring(0, policyId.length - asciiEncodedAssetName.length); + } + + const policyScriptHash = CardanoWasm.ScriptHash.from_hex(policyId); + const assetName = CardanoWasm.AssetName.new(Buffer.from(asciiEncodedAssetName, 'hex')); + + for (const recipient of txParams.recipients) { + const found = txJson.outputs.some((output) => { + if (recipient.address !== output.address || !output.multiAssets) { + return false; + } + const multiAssets = output.multiAssets as CardanoWasm.MultiAsset; + const tokenQty = multiAssets.get_asset(policyScriptHash, assetName); + return tokenQty && tokenQty.to_str() === recipient.amount; + }); + + if (!found) { + throw new Error('cannot find recipient in expected output'); + } + } + } + } catch (e) { + if (e instanceof NodeEnvironmentError) { + return true; + } + throw e; + } + return true; + } } diff --git a/modules/sdk-coin-ada/test/unit/tokenWithdrawal.ts b/modules/sdk-coin-ada/test/unit/tokenWithdrawal.ts index 5438060764..7719cf15fc 100644 --- a/modules/sdk-coin-ada/test/unit/tokenWithdrawal.ts +++ b/modules/sdk-coin-ada/test/unit/tokenWithdrawal.ts @@ -1,10 +1,12 @@ import should from 'should'; import { TransactionType } from '@bitgo/sdk-core'; import * as testData from '../resources'; -import { TransactionBuilderFactory } from '../../src'; +import { TransactionBuilderFactory, AdaToken } from '../../src'; import { coins } from '@bitgo/statics'; import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs'; import { Transaction } from '../../src/lib/transaction'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; describe('ADA Token Operations', async () => { const factory = new TransactionBuilderFactory(coins.get('tada')); @@ -326,4 +328,302 @@ describe('ADA Token Operations', async () => { await txBuilder.build().should.not.be.rejected(); }); + + describe('AdaToken verifyTransaction', () => { + let bitgo: TestBitGoAPI; + let adaToken; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + const tokenConfig = { + type: 'tada:water', + coin: 'tada', + network: 'Testnet' as const, + name: 'WATER', + decimalPlaces: 0, + policyId: policyId, + assetName: name, // ASCII 'WATER', not hex-encoded + contractAddress: `${policyId}:${asciiEncodedName}`, + }; + adaToken = new AdaToken(bitgo, tokenConfig); + }); + + it('should verify a token transaction with correct token amount', async () => { + const quantity = '20'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + txBuilder.output({ + address: receiverAddress, + amount: '0', + multiAssets: { + asset_name: asciiEncodedName, + policy_id: policyId, + quantity, + fingerprint, + }, + }); + + txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList); + txBuilder.ttl(800000000); + txBuilder.isTokenTransaction(); + const tx = (await txBuilder.build()) as Transaction; + const txHex = tx.toBroadcastFormat(); + + // Verify transaction with correct token amount + const txParams = { + recipients: [ + { + address: receiverAddress, + amount: quantity, // Token amount, not ADA amount + }, + ], + }; + + const txPrebuild = { txHex }; + const isVerified = await adaToken.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + + it('should fail to verify a token transaction with incorrect token amount', async () => { + const quantity = '20'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + txBuilder.output({ + address: receiverAddress, + amount: '0', + multiAssets: { + asset_name: asciiEncodedName, + policy_id: policyId, + quantity, + fingerprint, + }, + }); + + txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList); + txBuilder.ttl(800000000); + txBuilder.isTokenTransaction(); + const tx = (await txBuilder.build()) as Transaction; + const txHex = tx.toBroadcastFormat(); + + // Verify transaction with WRONG token amount (should fail) + const txParams = { + recipients: [ + { + address: receiverAddress, + amount: '999', // Wrong amount + }, + ], + }; + + const txPrebuild = { txHex }; + await adaToken + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('cannot find recipient in expected output'); + }); + + it('should fail to verify when address does not match', async () => { + const quantity = '20'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + txBuilder.output({ + address: receiverAddress, + amount: '0', + multiAssets: { + asset_name: asciiEncodedName, + policy_id: policyId, + quantity, + fingerprint, + }, + }); + + txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList); + txBuilder.ttl(800000000); + txBuilder.isTokenTransaction(); + const tx = (await txBuilder.build()) as Transaction; + const txHex = tx.toBroadcastFormat(); + + // Verify with wrong address (should fail) + const txParams = { + recipients: [ + { + address: + 'addr_test1qqa86e3d7lfpwu0k2rhjz76ecmfxdr74s9kf9yfcp5hj5vmnh6xccjcclrk8jtaw9jgeuy99p2n8smtdpylmy45qjjfsfmp3g6', + amount: quantity, + }, + ], + }; + + const txPrebuild = { txHex }; + await adaToken + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('cannot find recipient in expected output'); + }); + + it('should verify transaction when policyId has concatenated assetName (crypto compare format)', async () => { + // This tests the case where policyId in tokenConfig contains policyId + asciiEncodedAssetName + // which is consistent with crypto compare format + const concatenatedPolicyId = policyId + asciiEncodedName; // e.g., 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed725741544552' + + const tokenConfigWithConcatenatedPolicyId = { + type: 'tada:water', + coin: 'tada', + network: 'Testnet' as const, + name: 'WATER', + decimalPlaces: 0, + policyId: concatenatedPolicyId, // policyId + assetName hex + assetName: name, // ASCII name 'WATER' (not hex encoded) + contractAddress: `${policyId}:${asciiEncodedName}`, + }; + const adaTokenWithConcatenatedPolicyId = new AdaToken(bitgo, tokenConfigWithConcatenatedPolicyId); + + const quantity = '20'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + txBuilder.output({ + address: receiverAddress, + amount: '0', + multiAssets: { + asset_name: asciiEncodedName, + policy_id: policyId, + quantity, + fingerprint, + }, + }); + + txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList); + txBuilder.ttl(800000000); + txBuilder.isTokenTransaction(); + const tx = (await txBuilder.build()) as Transaction; + const txHex = tx.toBroadcastFormat(); + + // Verify transaction - the verifyTransaction should strip the assetName from policyId + const txParams = { + recipients: [ + { + address: receiverAddress, + amount: quantity, + }, + ], + }; + + const txPrebuild = { txHex }; + const isVerified = await adaTokenWithConcatenatedPolicyId.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + + it('should verify transaction with policyId that does not have concatenated assetName', async () => { + // This tests the case where policyId is just the 28-byte policy ID (no assetName appended) + const tokenConfigWithPlainPolicyId = { + type: 'tada:water', + coin: 'tada', + network: 'Testnet' as const, + name: 'WATER', + decimalPlaces: 0, + policyId: policyId, // Just the policy ID without assetName + assetName: name, // ASCII name 'WATER' + contractAddress: `${policyId}:${asciiEncodedName}`, + }; + const adaTokenWithPlainPolicyId = new AdaToken(bitgo, tokenConfigWithPlainPolicyId); + + const quantity = '20'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + txBuilder.output({ + address: receiverAddress, + amount: '0', + multiAssets: { + asset_name: asciiEncodedName, + policy_id: policyId, + quantity, + fingerprint, + }, + }); + + txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList); + txBuilder.ttl(800000000); + txBuilder.isTokenTransaction(); + const tx = (await txBuilder.build()) as Transaction; + const txHex = tx.toBroadcastFormat(); + + // Verify transaction - should work with plain policyId as well + const txParams = { + recipients: [ + { + address: receiverAddress, + amount: quantity, + }, + ], + }; + + const txPrebuild = { txHex }; + const isVerified = await adaTokenWithPlainPolicyId.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + }); });