From 70c8834a4a5176d746aadb35b6f7f62ce56df314 Mon Sep 17 00:00:00 2001 From: Dhanush GM Date: Mon, 23 Mar 2026 12:31:43 +0530 Subject: [PATCH] fix(sdk-coin-ada): unsigned sweep support for WRW with asset list Ticket: CSHLD-134 Made-with: Cursor --- modules/sdk-coin-ada/src/ada.ts | 25 ++++--- modules/sdk-coin-ada/src/lib/transaction.ts | 34 ++++++++- modules/sdk-coin-ada/test/unit/ada.ts | 79 +++++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 31ad91e11a..06d021a922 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -35,6 +35,7 @@ import { TssVerifyAddressOptions, } from '@bitgo/sdk-core'; import { KeyPair as AdaKeyPair, Transaction, TransactionBuilderFactory, Utils } from './lib'; +import type { Asset } from './lib/transaction'; import { BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; import adaUtils from './lib/utils'; import * as request from 'superagent'; @@ -213,7 +214,7 @@ export class Ada extends BaseCoin { throw new Error('Invalid transaction'); } - return rebuiltTransaction.explainTransaction(); + return rebuiltTransaction.explainTransaction() as unknown as AdaTransactionExplanation; } async parseTransaction(params: AdaParseTransactionOptions): Promise { @@ -499,21 +500,25 @@ export class Ada extends BaseCoin { const transactionPrebuild = { txHex: serializedTx }; const parsedTx = await this.parseTransaction({ txPrebuild: transactionPrebuild }); const walletCoin = this.getChain(); - const output = (parsedTx.outputs as ITransactionRecipient)[0]; + const parsedOutputs = parsedTx.outputs as (ITransactionRecipient & { multiAssets?: Asset[] })[]; + const output = parsedOutputs[0]; + // All tokens from the spent UTXOs — shown on the input so WRW can display what is being swept + const inputAssetList = Object.values(aggregatedAssetList); const inputs = [ { address: senderAddr, valueString: output.amount, - value: new BigNumber(output.amount).toNumber(), - }, - ]; - const outputs = [ - { - address: output.address, - valueString: output.amount, - coinName: walletCoin, + value: new BigNumber(output.amount as string).toNumber(), + ...(inputAssetList.length > 0 && { multiAssets: inputAssetList }), }, ]; + // assetList per output comes from explainTransaction which parses multiAssets + const outputs = parsedOutputs.map((o) => ({ + address: o.address, + valueString: o.amount, + coinName: walletCoin, + ...(o.multiAssets && { multiAssets: o.multiAssets }), + })); const spendAmount = output.amount; const completedParsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' }; const fee = new BigNumber((parsedTx.fee as { fee: string }).fee); diff --git a/modules/sdk-coin-ada/src/lib/transaction.ts b/modules/sdk-coin-ada/src/lib/transaction.ts index 1fa4cb90ea..e10429d509 100644 --- a/modules/sdk-coin-ada/src/lib/transaction.ts +++ b/modules/sdk-coin-ada/src/lib/transaction.ts @@ -381,7 +381,7 @@ export class Transaction extends BaseTransaction { /** @inheritdoc */ explainTransaction(): { - outputs: { amount: string; address: string }[]; + outputs: { amount: string; address: string; multiAssets?: Asset[] }[]; certificates: Cert[]; changeOutputs: string[]; outputAmount: string; @@ -414,7 +414,14 @@ export class Transaction extends BaseTransaction { return { displayOrder, id: txJson.id, - outputs: txJson.outputs.map((o) => ({ address: o.address, amount: o.amount })), + outputs: txJson.outputs.map((o) => { + const multiAssets = Transaction.parseMultiAssets(o.multiAssets as CardanoWasm.MultiAsset | undefined); + return { + address: o.address, + amount: o.amount, + ...(multiAssets && { multiAssets }), + }; + }), outputAmount: outputAmount, changeOutputs: [], changeAmount: '0', @@ -426,6 +433,29 @@ export class Transaction extends BaseTransaction { }; } + private static parseMultiAssets(multiAssets: CardanoWasm.MultiAsset | undefined): Asset[] | undefined { + if (!multiAssets) return undefined; + const list: Asset[] = []; + const scriptHashes = multiAssets.keys(); + for (let i = 0; i < scriptHashes.len(); i++) { + const scriptHash = scriptHashes.get(i); + const assets = multiAssets.get(scriptHash); + if (!assets) continue; + const assetNames = assets.keys(); + for (let j = 0; j < assetNames.len(); j++) { + const assetName = assetNames.get(j); + const quantity = assets.get(assetName); + if (!quantity) continue; + list.push({ + policy_id: Buffer.from(scriptHash.to_bytes()).toString('hex'), + asset_name: Buffer.from(assetName.name()).toString('hex'), + quantity: quantity.to_str(), + }); + } + } + return list.length > 0 ? list : undefined; + } + getPledgeDetails(): PledgeDetails | undefined { return this._pledgeDetails; } diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index fd324cbab0..b23bf27556 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -674,6 +674,85 @@ describe('ADA', function () { should.deepEqual(Number(txJson.outputs[0].amount) + fee, testnetUTXO.UTXO_1.value); }); + it('should recover ADA plus token UTXOs - token and ADA both appear in outputs (unsigned sweep)', async function () { + callBack + .withArgs('address_info', { _addresses: [wrwUser.walletAddress0] }) + .resolves(endpointResponses.addressInfoResponse.ADAAndTokenUTXOs); + + const res = await basecoin.recover({ + bitgoKey: wrwUser.bitgoKey, + recoveryDestination: destAddr, + }); + res.should.not.be.empty(); + const unsignedTx = res.txRequests[0].transactions[0].unsignedTx; + unsignedTx.should.hasOwnProperty('serializedTx'); + + const tx = new Transaction(basecoin); + tx.fromRawTransaction(unsignedTx.serializedTx); + const txJson = tx.toJson(); + + txJson.inputs.length.should.equal(2); + should.deepEqual(txJson.inputs[0].transaction_id, testnetUTXO.UTXO_1.tx_hash); + should.deepEqual(txJson.inputs[1].transaction_id, testnetUTXO.UTXO_TOKEN.tx_hash); + + txJson.outputs.length.should.equal(2); + + const tokenPolicyId = '2533cca6eb42076e144e9f2772c390dece9fce173bc38c72294b3924'; + const tokenEncodedAssetName = '5741544552'; + const tokenQuantity = '111'; + const minADAForToken = 1500000; + + const tokenOutput = txJson.outputs.find((o) => o.multiAssets !== undefined); + should.exist(tokenOutput); + should.deepEqual(tokenOutput!.address, destAddr); + should.deepEqual(Number(tokenOutput!.amount), minADAForToken); + const expectedPolicyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(tokenPolicyId, 'hex')); + const expectedAssetName = CardanoWasm.AssetName.new(Buffer.from(tokenEncodedAssetName, 'hex')); + (tokenOutput!.multiAssets as CardanoWasm.MultiAsset) + .get_asset(expectedPolicyId, expectedAssetName) + .to_str() + .should.equal(tokenQuantity); + + const adaOutput = txJson.outputs.find((o) => o.multiAssets === undefined); + should.exist(adaOutput); + should.deepEqual(adaOutput!.address, destAddr); + const fee = Number(tx.explainTransaction().fee.fee); + const totalBalance = testnetUTXO.UTXO_1.value + testnetUTXO.UTXO_TOKEN.value; + should.deepEqual(Number(adaOutput!.amount), totalBalance - minADAForToken - fee); + + const explained = tx.explainTransaction(); + const explainedTokenOutput = explained.outputs.find((o) => o.amount === minADAForToken.toString()); + should.exist(explainedTokenOutput); + + // inputs and outputs in parsedTx should carry multiAssets + const parsedTxInputs = res.txRequests[0].transactions[0].unsignedTx.parsedTx.inputs as { + address: string; + valueString: string; + multiAssets?: { policy_id: string; asset_name: string; quantity: string }[]; + }[]; + parsedTxInputs.length.should.equal(1); + should.exist(parsedTxInputs[0].multiAssets); + parsedTxInputs[0].multiAssets!.length.should.equal(1); + should.deepEqual(parsedTxInputs[0].multiAssets![0].policy_id, tokenPolicyId); + should.deepEqual(parsedTxInputs[0].multiAssets![0].asset_name, tokenEncodedAssetName); + should.deepEqual(parsedTxInputs[0].multiAssets![0].quantity, tokenQuantity); + + const parsedTxOutputs = res.txRequests[0].transactions[0].unsignedTx.parsedTx.outputs as { + address: string; + valueString: string; + multiAssets?: { policy_id: string; asset_name: string; quantity: string }[]; + }[]; + const parsedTokenOutput = parsedTxOutputs.find((o) => o.multiAssets !== undefined); + should.exist(parsedTokenOutput); + parsedTokenOutput!.multiAssets!.length.should.equal(1); + should.deepEqual(parsedTokenOutput!.multiAssets![0].policy_id, tokenPolicyId); + should.deepEqual(parsedTokenOutput!.multiAssets![0].asset_name, tokenEncodedAssetName); + should.deepEqual(parsedTokenOutput!.multiAssets![0].quantity, tokenQuantity); + // pure ADA output should not have multiAssets + const parsedAdaOutput = parsedTxOutputs.find((o) => o.multiAssets === undefined); + should.exist(parsedAdaOutput); + }); + it('should recover ADA plus token UTXOs - token and ADA both appear in outputs (signed)', async function () { callBack .withArgs('address_info', { _addresses: [wrwUser.walletAddress0] })