Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion modules/sdk-coin-ada/src/adaToken.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<boolean> {
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');
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please add the consolidation IF case as well so that we do not have to remember this touchpoint in single asset consolidation of tokens?

} catch (e) {
if (e instanceof NodeEnvironmentError) {
return true;
}
throw e;
}
return true;
}
}
302 changes: 301 additions & 1 deletion modules/sdk-coin-ada/test/unit/tokenWithdrawal.ts
Original file line number Diff line number Diff line change
@@ -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'));
Expand Down Expand Up @@ -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);
});
});
});
Loading