diff --git a/modules/sdk-coin-ada/src/adaToken.ts b/modules/sdk-coin-ada/src/adaToken.ts index f341c82c14..20126a5970 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,72 @@ 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. + * For token consolidation, we verify all outputs go to the base address. + * + * @param params.txPrebuild prebuild transaction + * @param params.txParams transaction parameters + * @param params.verification verification options (includes consolidationToBaseAddress flag) + * @param params.wallet wallet object for getting base address + * @return true if verification success + */ + async verifyTransaction(params: VerifyTransactionOptions): Promise { + try { + const coinConfig = coins.get(this.getBaseChain()); + const { txPrebuild, txParams, verification, wallet } = 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'); + } + } + } else if (verification?.consolidationToBaseAddress) { + // For token consolidation, verify all outputs go to the base address + const baseAddress = wallet?.coinSpecific()?.baseAddress || wallet?.coinSpecific()?.rootAddress; + + for (const output of txJson.outputs) { + if (output.address !== baseAddress) { + throw new Error('tx outputs does not match with expected address'); + } + } + } + } 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 03f7e94da8..d95265324e 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')); @@ -398,4 +400,421 @@ describe('ADA Token Operations', async () => { // Fee address change should not have any tokens should.not.exist(feeAddressChangeOutput[0].multiAssets); }); + + 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); + }); + + it('should verify token consolidation transaction when all outputs go to base address', async () => { + const quantity = '100'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + // Build a consolidation transaction - all outputs go to sender (base) address + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + // For consolidation, tokens go back to the sender's base address + txBuilder.output({ + address: senderAddress, + 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(); + + // Mock wallet with coinSpecific returning base address + const mockWallet = { + coinSpecific: () => ({ + baseAddress: senderAddress, + }), + }; + + // Verify consolidation transaction - no recipients, but consolidationToBaseAddress is true + const txParams = { + recipients: undefined, + }; + + const txPrebuild = { txHex }; + const verification = { consolidationToBaseAddress: true }; + + const isVerified = await adaToken.verifyTransaction({ + txParams, + txPrebuild, + verification, + wallet: mockWallet as any, + }); + isVerified.should.equal(true); + }); + + it('should fail token consolidation when output address does not match base address', async () => { + const quantity = '100'; + const totalInput = 20000000; + const totalAssetList = { + [fingerprint]: { + quantity: '100', + policy_id: policyId, + asset_name: asciiEncodedName, + }, + }; + + // Build a transaction with output to receiver (not base address) + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + txBuilder.output({ + address: receiverAddress, // Output goes to receiver, not base address + 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(); + + // Mock wallet with different base address + const mockWallet = { + coinSpecific: () => ({ + baseAddress: senderAddress, // Base address is sender, but output goes to receiver + }), + }; + + const txParams = { + recipients: undefined, + }; + + const txPrebuild = { txHex }; + const verification = { consolidationToBaseAddress: true }; + + await adaToken + .verifyTransaction({ + txParams, + txPrebuild, + verification, + wallet: mockWallet as any, + }) + .should.be.rejectedWith('tx outputs does not match with expected address'); + }); + }); });