From 95b673fcec46aeefb2c6d48dc9183f1e80c30593 Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Thu, 4 Jun 2026 10:50:07 +0000 Subject: [PATCH] fix(sdk-coin-ada): mark change outputs in explainTransaction ADA's explainTransaction() returned all outputs in a flat array with no change marker, causing intent verification to count change outputs as recipients and fire false-positive TransactionFailsIntentVerification alerts. Add _changeAddress to Transaction, set it from all three build paths in TransactionBuilder, and mark matching outputs with change: true in explainTransaction(). Also add change?: boolean to the explainTransaction return type. Ticket: WCI-633 Co-Authored-By: Claude Opus 4.8 Session-Id: 9d7a233b-1b25-4206-87ef-85d2a74410a4 Task-Id: 2eafb02e-6fe8-44a3-a0e9-1825491a8e31 --- modules/sdk-coin-ada/src/lib/transaction.ts | 13 +++- .../src/lib/transactionBuilder.ts | 9 +++ .../test/unit/transactionBuilder.ts | 67 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/modules/sdk-coin-ada/src/lib/transaction.ts b/modules/sdk-coin-ada/src/lib/transaction.ts index 21e8bcd3ac..6ee96e034b 100644 --- a/modules/sdk-coin-ada/src/lib/transaction.ts +++ b/modules/sdk-coin-ada/src/lib/transaction.ts @@ -101,6 +101,7 @@ export class Transaction extends BaseTransaction { private _transaction: CardanoWasm.Transaction; private _fee: string; private _pledgeDetails?: PledgeDetails; + private _changeAddress?: string; constructor(coinConfig: Readonly) { super(coinConfig); @@ -391,7 +392,7 @@ export class Transaction extends BaseTransaction { /** @inheritdoc */ explainTransaction(): { - outputs: { amount: string; address: string; multiAssets?: Asset[] }[]; + outputs: { amount: string; address: string; multiAssets?: Asset[]; change?: boolean }[]; certificates: Cert[]; changeOutputs: string[]; outputAmount: string; @@ -426,10 +427,12 @@ export class Transaction extends BaseTransaction { id: txJson.id, outputs: txJson.outputs.map((o) => { const multiAssets = Transaction.parseMultiAssets(o.multiAssets as CardanoWasm.MultiAsset | undefined); + const isChange = this._changeAddress !== undefined && o.address === this._changeAddress; return { address: o.address, amount: o.amount, ...(multiAssets && { multiAssets }), + ...(isChange && { change: true }), }; }), outputAmount: outputAmount, @@ -485,6 +488,14 @@ export class Transaction extends BaseTransaction { fee(fee: string) { this._fee = fee; } + + set changeAddress(address: string) { + this._changeAddress = address; + } + + get changeAddress(): string | undefined { + return this._changeAddress; + } } export interface SponsorshipInfo { diff --git a/modules/sdk-coin-ada/src/lib/transactionBuilder.ts b/modules/sdk-coin-ada/src/lib/transactionBuilder.ts index 16edafe0a9..ec363a6292 100644 --- a/modules/sdk-coin-ada/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-ada/src/lib/transactionBuilder.ts @@ -227,6 +227,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this.setMutableSenderAssetList(); this.addOutputs(outputs); this._transaction.transaction = this.prepareAdaTransactionDraft(inputs, outputs, true); + if (this._changeAddress) { + this._transaction.changeAddress = this._changeAddress; + } return this.transaction; } @@ -573,6 +576,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { const finalOutputs = this.buildExplicitOutputsCollection(this._fee); this._transaction.transaction = this.prepareAdaTransactionDraft(inputs, finalOutputs, true); + if (this._changeAddress) { + this._transaction.changeAddress = this._changeAddress; + } return this.transaction; } @@ -924,6 +930,9 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { }); witnessSet.set_vkeys(vkeyWitnesses); this._transaction.transaction = CardanoWasm.Transaction.new(txRaw, witnessSet); + if (this._changeAddress) { + this._transaction.changeAddress = this._changeAddress; + } return this.transaction; } diff --git a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts index 8055657bc9..7c722591dc 100644 --- a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts @@ -549,4 +549,71 @@ describe('ADA Transaction Builder', async () => { // console.log(err); // } // }); + + describe('explainTransaction change output marking', () => { + it('should mark the change output with change: true for a shelley send tx', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + const outputAmount = 7823121; + txBuilder.output({ + address: testData.rawTx.outputAddress1.address, + amount: outputAmount.toString(), + }); + const totalInput = 21032023; + txBuilder.changeAddress(testData.rawTx.outputAddress2.address, totalInput.toString()); + txBuilder.ttl(800000000); + const tx = (await txBuilder.build()) as Transaction; + const explained = tx.explainTransaction(); + explained.outputs.length.should.equal(2); + const recipientOutput = explained.outputs.find((o) => o.address === testData.rawTx.outputAddress1.address); + const changeOutput = explained.outputs.find((o) => o.address === testData.rawTx.outputAddress2.address); + should.exist(recipientOutput); + should.exist(changeOutput); + should.not.exist(recipientOutput!.change); + changeOutput!.change!.should.be.true(); + }); + + it('should mark the change output with change: true for a byron send tx', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '1b53331e069a6e58fe77919d30c0cf299d13a2f5b3d9970ce473c1a66d71bf03', + transaction_index: 1, + }); + const outputAmount = 200000000; + txBuilder.output({ + address: testData.rawTxByron.outputAddress1.address, + amount: outputAmount.toString(), + }); + const totalInput = 999600000; + txBuilder.changeAddress(testData.rawTxByron.outputAddress2.address, totalInput.toString()); + txBuilder.ttl(800000000); + const tx = (await txBuilder.build()) as Transaction; + const explained = tx.explainTransaction(); + explained.outputs.length.should.equal(2); + const recipientOutput = explained.outputs.find((o) => o.address === testData.rawTxByron.outputAddress1.address); + const changeOutput = explained.outputs.find((o) => o.address === testData.rawTxByron.outputAddress2.address); + should.exist(recipientOutput); + should.exist(changeOutput); + should.not.exist(recipientOutput!.change); + changeOutput!.change!.should.be.true(); + }); + + it('should not set change on any output when no change address is set (consolidation)', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + const totalInput = 20000000; + txBuilder.changeAddress(testData.rawTx.outputAddress1.address, totalInput.toString()); + txBuilder.ttl(800000000); + const tx = (await txBuilder.build()) as Transaction; + const explained = tx.explainTransaction(); + explained.outputs.length.should.equal(1); + explained.outputs[0].change!.should.be.true(); + }); + }); });