Skip to content

Commit 074b8f9

Browse files
committed
fix(sdk-coin-ada): token verifytransaction
TICKET: COIN-7292
1 parent da70144 commit 074b8f9

File tree

2 files changed

+367
-2
lines changed

2 files changed

+367
-2
lines changed

modules/sdk-coin-ada/src/adaToken.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { Ada } from './ada';
2-
import { BitGoBase, CoinConstructor, NamedCoinConstructor } from '@bitgo/sdk-core';
2+
import {
3+
BitGoBase,
4+
CoinConstructor,
5+
NamedCoinConstructor,
6+
VerifyTransactionOptions,
7+
NodeEnvironmentError,
8+
} from '@bitgo/sdk-core';
39
import { coins, tokens, AdaTokenConfig } from '@bitgo/statics';
10+
import { Transaction } from './lib';
11+
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
12+
import assert from 'assert';
413

514
export class AdaToken extends Ada {
615
public readonly tokenConfig: AdaTokenConfig;
@@ -85,4 +94,60 @@ export class AdaToken extends Ada {
8594
get contractAddress() {
8695
return this.tokenConfig.contractAddress;
8796
}
97+
98+
/**
99+
* Verify that a token transaction prebuild complies with the original intention.
100+
* For token transfers, we need to verify the token amount in multiAssets, not the ADA amount.
101+
*
102+
* @param params.txPrebuild prebuild transaction
103+
* @param params.txParams transaction parameters
104+
* @return true if verification success
105+
*/
106+
async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
107+
try {
108+
const coinConfig = coins.get(this.getBaseChain());
109+
const { txPrebuild, txParams } = params;
110+
const transaction = new Transaction(coinConfig);
111+
assert(txPrebuild.txHex, new Error('missing required tx prebuild property txHex'));
112+
113+
transaction.fromRawTransaction(txPrebuild.txHex);
114+
const txJson = transaction.toJson();
115+
116+
if (txParams.recipients !== undefined) {
117+
// assetName in tokenConfig is ASCII (e.g. 'WATER'), convert to hex for comparison
118+
const asciiEncodedAssetName = Buffer.from(this.tokenConfig.assetName).toString('hex');
119+
120+
// ASCII encoded asset name may be appended to the policy ID (consistent with crypto compare)
121+
// But cardano sdk requires only the policy ID (28 bytes = 56 hex chars) for ScriptHash
122+
let policyId = this.tokenConfig.policyId;
123+
if (policyId.endsWith(asciiEncodedAssetName)) {
124+
policyId = policyId.substring(0, policyId.length - asciiEncodedAssetName.length);
125+
}
126+
127+
const policyScriptHash = CardanoWasm.ScriptHash.from_hex(policyId);
128+
const assetName = CardanoWasm.AssetName.new(Buffer.from(asciiEncodedAssetName, 'hex'));
129+
130+
for (const recipient of txParams.recipients) {
131+
const found = txJson.outputs.some((output) => {
132+
if (recipient.address !== output.address || !output.multiAssets) {
133+
return false;
134+
}
135+
const multiAssets = output.multiAssets as CardanoWasm.MultiAsset;
136+
const tokenQty = multiAssets.get_asset(policyScriptHash, assetName);
137+
return tokenQty && tokenQty.to_str() === recipient.amount;
138+
});
139+
140+
if (!found) {
141+
throw new Error('cannot find recipient in expected output');
142+
}
143+
}
144+
}
145+
} catch (e) {
146+
if (e instanceof NodeEnvironmentError) {
147+
return true;
148+
}
149+
throw e;
150+
}
151+
return true;
152+
}
88153
}

modules/sdk-coin-ada/test/unit/tokenWithdrawal.ts

Lines changed: 301 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import should from 'should';
22
import { TransactionType } from '@bitgo/sdk-core';
33
import * as testData from '../resources';
4-
import { TransactionBuilderFactory } from '../../src';
4+
import { TransactionBuilderFactory, AdaToken } from '../../src';
55
import { coins } from '@bitgo/statics';
66
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
77
import { Transaction } from '../../src/lib/transaction';
8+
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
9+
import { BitGoAPI } from '@bitgo/sdk-api';
810

911
describe('ADA Token Operations', async () => {
1012
const factory = new TransactionBuilderFactory(coins.get('tada'));
@@ -326,4 +328,302 @@ describe('ADA Token Operations', async () => {
326328

327329
await txBuilder.build().should.not.be.rejected();
328330
});
331+
332+
describe('AdaToken verifyTransaction', () => {
333+
let bitgo: TestBitGoAPI;
334+
let adaToken;
335+
336+
before(function () {
337+
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' });
338+
bitgo.initializeTestVars();
339+
const tokenConfig = {
340+
type: 'tada:water',
341+
coin: 'tada',
342+
network: 'Testnet' as const,
343+
name: 'WATER',
344+
decimalPlaces: 0,
345+
policyId: policyId,
346+
assetName: name, // ASCII 'WATER', not hex-encoded
347+
contractAddress: `${policyId}:${asciiEncodedName}`,
348+
};
349+
adaToken = new AdaToken(bitgo, tokenConfig);
350+
});
351+
352+
it('should verify a token transaction with correct token amount', async () => {
353+
const quantity = '20';
354+
const totalInput = 20000000;
355+
const totalAssetList = {
356+
[fingerprint]: {
357+
quantity: '100',
358+
policy_id: policyId,
359+
asset_name: asciiEncodedName,
360+
},
361+
};
362+
363+
const txBuilder = factory.getTransferBuilder();
364+
txBuilder.input({
365+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
366+
transaction_index: 1,
367+
});
368+
369+
txBuilder.output({
370+
address: receiverAddress,
371+
amount: '0',
372+
multiAssets: {
373+
asset_name: asciiEncodedName,
374+
policy_id: policyId,
375+
quantity,
376+
fingerprint,
377+
},
378+
});
379+
380+
txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList);
381+
txBuilder.ttl(800000000);
382+
txBuilder.isTokenTransaction();
383+
const tx = (await txBuilder.build()) as Transaction;
384+
const txHex = tx.toBroadcastFormat();
385+
386+
// Verify transaction with correct token amount
387+
const txParams = {
388+
recipients: [
389+
{
390+
address: receiverAddress,
391+
amount: quantity, // Token amount, not ADA amount
392+
},
393+
],
394+
};
395+
396+
const txPrebuild = { txHex };
397+
const isVerified = await adaToken.verifyTransaction({ txParams, txPrebuild });
398+
isVerified.should.equal(true);
399+
});
400+
401+
it('should fail to verify a token transaction with incorrect token amount', async () => {
402+
const quantity = '20';
403+
const totalInput = 20000000;
404+
const totalAssetList = {
405+
[fingerprint]: {
406+
quantity: '100',
407+
policy_id: policyId,
408+
asset_name: asciiEncodedName,
409+
},
410+
};
411+
412+
const txBuilder = factory.getTransferBuilder();
413+
txBuilder.input({
414+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
415+
transaction_index: 1,
416+
});
417+
418+
txBuilder.output({
419+
address: receiverAddress,
420+
amount: '0',
421+
multiAssets: {
422+
asset_name: asciiEncodedName,
423+
policy_id: policyId,
424+
quantity,
425+
fingerprint,
426+
},
427+
});
428+
429+
txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList);
430+
txBuilder.ttl(800000000);
431+
txBuilder.isTokenTransaction();
432+
const tx = (await txBuilder.build()) as Transaction;
433+
const txHex = tx.toBroadcastFormat();
434+
435+
// Verify transaction with WRONG token amount (should fail)
436+
const txParams = {
437+
recipients: [
438+
{
439+
address: receiverAddress,
440+
amount: '999', // Wrong amount
441+
},
442+
],
443+
};
444+
445+
const txPrebuild = { txHex };
446+
await adaToken
447+
.verifyTransaction({ txParams, txPrebuild })
448+
.should.be.rejectedWith('cannot find recipient in expected output');
449+
});
450+
451+
it('should fail to verify when address does not match', async () => {
452+
const quantity = '20';
453+
const totalInput = 20000000;
454+
const totalAssetList = {
455+
[fingerprint]: {
456+
quantity: '100',
457+
policy_id: policyId,
458+
asset_name: asciiEncodedName,
459+
},
460+
};
461+
462+
const txBuilder = factory.getTransferBuilder();
463+
txBuilder.input({
464+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
465+
transaction_index: 1,
466+
});
467+
468+
txBuilder.output({
469+
address: receiverAddress,
470+
amount: '0',
471+
multiAssets: {
472+
asset_name: asciiEncodedName,
473+
policy_id: policyId,
474+
quantity,
475+
fingerprint,
476+
},
477+
});
478+
479+
txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList);
480+
txBuilder.ttl(800000000);
481+
txBuilder.isTokenTransaction();
482+
const tx = (await txBuilder.build()) as Transaction;
483+
const txHex = tx.toBroadcastFormat();
484+
485+
// Verify with wrong address (should fail)
486+
const txParams = {
487+
recipients: [
488+
{
489+
address:
490+
'addr_test1qqa86e3d7lfpwu0k2rhjz76ecmfxdr74s9kf9yfcp5hj5vmnh6xccjcclrk8jtaw9jgeuy99p2n8smtdpylmy45qjjfsfmp3g6',
491+
amount: quantity,
492+
},
493+
],
494+
};
495+
496+
const txPrebuild = { txHex };
497+
await adaToken
498+
.verifyTransaction({ txParams, txPrebuild })
499+
.should.be.rejectedWith('cannot find recipient in expected output');
500+
});
501+
502+
it('should verify transaction when policyId has concatenated assetName (crypto compare format)', async () => {
503+
// This tests the case where policyId in tokenConfig contains policyId + asciiEncodedAssetName
504+
// which is consistent with crypto compare format
505+
const concatenatedPolicyId = policyId + asciiEncodedName; // e.g., 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed725741544552'
506+
507+
const tokenConfigWithConcatenatedPolicyId = {
508+
type: 'tada:water',
509+
coin: 'tada',
510+
network: 'Testnet' as const,
511+
name: 'WATER',
512+
decimalPlaces: 0,
513+
policyId: concatenatedPolicyId, // policyId + assetName hex
514+
assetName: name, // ASCII name 'WATER' (not hex encoded)
515+
contractAddress: `${policyId}:${asciiEncodedName}`,
516+
};
517+
const adaTokenWithConcatenatedPolicyId = new AdaToken(bitgo, tokenConfigWithConcatenatedPolicyId);
518+
519+
const quantity = '20';
520+
const totalInput = 20000000;
521+
const totalAssetList = {
522+
[fingerprint]: {
523+
quantity: '100',
524+
policy_id: policyId,
525+
asset_name: asciiEncodedName,
526+
},
527+
};
528+
529+
const txBuilder = factory.getTransferBuilder();
530+
txBuilder.input({
531+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
532+
transaction_index: 1,
533+
});
534+
535+
txBuilder.output({
536+
address: receiverAddress,
537+
amount: '0',
538+
multiAssets: {
539+
asset_name: asciiEncodedName,
540+
policy_id: policyId,
541+
quantity,
542+
fingerprint,
543+
},
544+
});
545+
546+
txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList);
547+
txBuilder.ttl(800000000);
548+
txBuilder.isTokenTransaction();
549+
const tx = (await txBuilder.build()) as Transaction;
550+
const txHex = tx.toBroadcastFormat();
551+
552+
// Verify transaction - the verifyTransaction should strip the assetName from policyId
553+
const txParams = {
554+
recipients: [
555+
{
556+
address: receiverAddress,
557+
amount: quantity,
558+
},
559+
],
560+
};
561+
562+
const txPrebuild = { txHex };
563+
const isVerified = await adaTokenWithConcatenatedPolicyId.verifyTransaction({ txParams, txPrebuild });
564+
isVerified.should.equal(true);
565+
});
566+
567+
it('should verify transaction with policyId that does not have concatenated assetName', async () => {
568+
// This tests the case where policyId is just the 28-byte policy ID (no assetName appended)
569+
const tokenConfigWithPlainPolicyId = {
570+
type: 'tada:water',
571+
coin: 'tada',
572+
network: 'Testnet' as const,
573+
name: 'WATER',
574+
decimalPlaces: 0,
575+
policyId: policyId, // Just the policy ID without assetName
576+
assetName: name, // ASCII name 'WATER'
577+
contractAddress: `${policyId}:${asciiEncodedName}`,
578+
};
579+
const adaTokenWithPlainPolicyId = new AdaToken(bitgo, tokenConfigWithPlainPolicyId);
580+
581+
const quantity = '20';
582+
const totalInput = 20000000;
583+
const totalAssetList = {
584+
[fingerprint]: {
585+
quantity: '100',
586+
policy_id: policyId,
587+
asset_name: asciiEncodedName,
588+
},
589+
};
590+
591+
const txBuilder = factory.getTransferBuilder();
592+
txBuilder.input({
593+
transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21',
594+
transaction_index: 1,
595+
});
596+
597+
txBuilder.output({
598+
address: receiverAddress,
599+
amount: '0',
600+
multiAssets: {
601+
asset_name: asciiEncodedName,
602+
policy_id: policyId,
603+
quantity,
604+
fingerprint,
605+
},
606+
});
607+
608+
txBuilder.changeAddress(senderAddress, totalInput.toString(), totalAssetList);
609+
txBuilder.ttl(800000000);
610+
txBuilder.isTokenTransaction();
611+
const tx = (await txBuilder.build()) as Transaction;
612+
const txHex = tx.toBroadcastFormat();
613+
614+
// Verify transaction - should work with plain policyId as well
615+
const txParams = {
616+
recipients: [
617+
{
618+
address: receiverAddress,
619+
amount: quantity,
620+
},
621+
],
622+
};
623+
624+
const txPrebuild = { txHex };
625+
const isVerified = await adaTokenWithPlainPolicyId.verifyTransaction({ txParams, txPrebuild });
626+
isVerified.should.equal(true);
627+
});
628+
});
329629
});

0 commit comments

Comments
 (0)