From a1dd6017af52b2f0fdc9133d0cd0b7614e0a9b4d Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Thu, 4 Jun 2026 10:31:42 +0000 Subject: [PATCH] fix(sdk-coin-ada): mark change outputs with change:true in explainTransaction ADA's explainTransaction() returned all outputs in a flat outputs[] array with no change marker, causing intent verification to count change outputs as recipients and trigger false-positive TransactionFailsIntentVerification alerts. Fix: add a _changeAddress field to Transaction. TransactionBuilder sets it after building. explainTransaction() uses it to add change:true on matching outputs. ExplainTransactionOptions accepts an optional changeAddress so callers with only raw hex can supply the wallet address. Ticket: WCI-633 Session-Id: 9d7a233b-1b25-4206-87ef-85d2a74410a4 Task-Id: 2eafb02e-6fe8-44a3-a0e9-1825491a8e31 --- modules/sdk-coin-ada/src/ada.ts | 9 ++- modules/sdk-coin-ada/src/lib/transaction.ts | 7 +++ .../src/lib/transactionBuilder.ts | 9 +++ .../test/unit/transactionBuilder.ts | 62 +++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 265dcb3e1b..4d1621dc6b 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -54,6 +54,7 @@ export interface AdaTxInfo { export interface ExplainTransactionOptions { txPrebuild: TransactionPrebuild; + changeAddress?: string; } export interface AdaParseTransactionOptions extends BaseParseTransactionOptions { @@ -204,16 +205,20 @@ export class Ada extends BaseCoin { */ async explainTransaction(params: ExplainTransactionOptions): Promise { const factory = this.getBuilder(); - let rebuiltTransaction: BaseTransaction; + let rebuiltTransaction: Transaction; const txRaw = params.txPrebuild.txHex; try { const transactionBuilder = factory.from(txRaw); - rebuiltTransaction = await transactionBuilder.build(); + rebuiltTransaction = (await transactionBuilder.build()) as Transaction; } catch { throw new Error('Invalid transaction'); } + if (params.changeAddress) { + rebuiltTransaction.changeAddress = params.changeAddress; + } + return rebuiltTransaction.explainTransaction() as unknown as AdaTransactionExplanation; } diff --git a/modules/sdk-coin-ada/src/lib/transaction.ts b/modules/sdk-coin-ada/src/lib/transaction.ts index 21e8bcd3ac..9fdefdfc0b 100644 --- a/modules/sdk-coin-ada/src/lib/transaction.ts +++ b/modules/sdk-coin-ada/src/lib/transaction.ts @@ -101,6 +101,11 @@ export class Transaction extends BaseTransaction { private _transaction: CardanoWasm.Transaction; private _fee: string; private _pledgeDetails?: PledgeDetails; + private _changeAddress?: string; + + set changeAddress(address: string) { + this._changeAddress = address; + } constructor(coinConfig: Readonly) { super(coinConfig); @@ -426,10 +431,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, 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..09aaa1a60b 100644 --- a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts @@ -549,4 +549,66 @@ describe('ADA Transaction Builder', async () => { // console.log(err); // } // }); + + describe('explainTransaction change output marking', () => { + it('should mark the change output with change: true in explainTransaction', 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 changeAddr = testData.rawTx.outputAddress2.address; + const totalInput = 21032023; + txBuilder.changeAddress(changeAddr, totalInput.toString()); + txBuilder.ttl(800000000); + + const tx = (await txBuilder.build()) as Transaction; + const explained = tx.explainTransaction(); + + const changeOutputs = explained.outputs.filter((o) => o.change === true); + const nonChangeOutputs = explained.outputs.filter((o) => !o.change); + + changeOutputs.length.should.equal(1); + changeOutputs[0].address.should.equal(changeAddr); + nonChangeOutputs.length.should.equal(1); + nonChangeOutputs[0].address.should.equal(testData.rawTx.outputAddress1.address); + }); + + it('should not mark any output as change when no changeAddress is set', async () => { + const preBuiltTx = new Transaction(coins.get('tada')); + preBuiltTx.fromRawTransaction(testData.rawTx.unsignedTx2); + const txBuilder = factory.getTransferBuilder(); + txBuilder.initBuilder(preBuiltTx); + + const tx = (await txBuilder.build()) as Transaction; + const explained = tx.explainTransaction(); + + const changeOutputs = explained.outputs.filter((o) => o.change === true); + changeOutputs.length.should.equal(0); + }); + + it('should mark consolidation output with change: true when changeAddress matches the single output', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + const changeAddr = testData.rawTx.outputAddress1.address; + const totalInput = 20000000; + txBuilder.changeAddress(changeAddr, totalInput.toString()); + txBuilder.ttl(800000000); + + const tx = (await txBuilder.build()) as Transaction; + const explained = tx.explainTransaction(); + + const changeOutputs = explained.outputs.filter((o) => o.change === true); + changeOutputs.length.should.equal(1); + changeOutputs[0].address.should.equal(changeAddr); + }); + }); });