From 1d7ba704ed41dfbd4f051ff7e75f508fd0a0af5b Mon Sep 17 00:00:00 2001 From: Calvin Date: Tue, 4 Feb 2025 09:45:39 -0700 Subject: [PATCH] Allow Coin Selection Opt-Out (#237) * chore: allow coin selection opt-in or not for manual cases * chore: fmt * chore: changeset --- .changeset/metal-vans-begin.md | 5 ++ packages/blaze-tx/src/tx.ts | 103 +++++++++++++++----------- packages/blaze-tx/tests/tx/tx.test.ts | 37 +++++++++ 3 files changed, 100 insertions(+), 45 deletions(-) create mode 100644 .changeset/metal-vans-begin.md diff --git a/.changeset/metal-vans-begin.md b/.changeset/metal-vans-begin.md new file mode 100644 index 0000000..787f0c7 --- /dev/null +++ b/.changeset/metal-vans-begin.md @@ -0,0 +1,5 @@ +--- +"@blaze-cardano/tx": patch +--- + +Added the ability to opt-out of coin selection on complete. diff --git a/packages/blaze-tx/src/tx.ts b/packages/blaze-tx/src/tx.ts index 61f2742..e90a8b7 100644 --- a/packages/blaze-tx/src/tx.ts +++ b/packages/blaze-tx/src/tx.ts @@ -1528,7 +1528,7 @@ export class TxBuilder { * or if balancing the change output fails. * @returns {Promise} A new Transaction object with all components set and ready for submission. */ - async complete(): Promise { + async complete(coinSelection: boolean = true): Promise { // Execute pre-complete hooks if (this.preCompleteHooks && this.preCompleteHooks.length > 0) { for (const hook of this.preCompleteHooks) { @@ -1568,56 +1568,62 @@ export class TxBuilder { spareInputs.push(utxo); } } - // Perform coin selection to cover any negative excess value. - const selectionResult = this.coinSelector( - spareInputs, - value.negate(value.negatives(excessValue)), - ); - // Update the excess value and spare inputs based on the selection result. - excessValue = value.merge(excessValue, selectionResult.selectedValue); - spareInputs = selectionResult.inputs; - // Add selected inputs to the transaction. - if (selectionResult.selectedInputs.length > 0) { - for (const input of selectionResult.selectedInputs) { - this.addInput(input); - } - } else { - if (this.body.inputs().size() == 0) { - if (!spareInputs[0]) { - throw new Error( - "No spare inputs available to add to the transaction", - ); + + if (coinSelection) { + // Perform coin selection to cover any negative excess value. + const selectionResult = this.coinSelector( + spareInputs, + value.negate(value.negatives(excessValue)), + ); + // Update the excess value and spare inputs based on the selection result. + excessValue = value.merge(excessValue, selectionResult.selectedValue); + spareInputs = selectionResult.inputs; + // Add selected inputs to the transaction. + if (selectionResult.selectedInputs.length > 0) { + for (const input of selectionResult.selectedInputs) { + this.addInput(input); } - // Select the input with the least number of different multiassets from spareInputs - const [inputWithLeastMultiAssets] = spareInputs.reduce( - ([minInput, minMultiAssetCount], currentInput) => { - const currentMultiAssetCount = value.assetTypes( - currentInput.output().amount(), + } else { + if (this.body.inputs().size() == 0) { + if (!spareInputs[0]) { + throw new Error( + "No spare inputs available to add to the transaction", ); - return currentMultiAssetCount < minMultiAssetCount - ? [currentInput, minMultiAssetCount] - : [minInput, value.assetTypes(minInput.output().amount())]; - }, - [spareInputs[0], value.assetTypes(spareInputs[0].output().amount())], + } + // Select the input with the least number of different multiassets from spareInputs + const [inputWithLeastMultiAssets] = spareInputs.reduce( + ([minInput, minMultiAssetCount], currentInput) => { + const currentMultiAssetCount = value.assetTypes( + currentInput.output().amount(), + ); + return currentMultiAssetCount < minMultiAssetCount + ? [currentInput, minMultiAssetCount] + : [minInput, value.assetTypes(minInput.output().amount())]; + }, + [ + spareInputs[0], + value.assetTypes(spareInputs[0].output().amount()), + ], + ); + this.addInput(inputWithLeastMultiAssets); + // Remove the selected input from spareInputs + spareInputs = spareInputs.filter( + (input) => input !== inputWithLeastMultiAssets, + ); + } + } + if (this.body.inputs().values().length == 0) { + throw new Error( + "TxBuilder: resolved empty input set, cannot construct transaction!", ); - this.addInput(inputWithLeastMultiAssets); - // Remove the selected input from spareInputs - spareInputs = spareInputs.filter( - (input) => input !== inputWithLeastMultiAssets, + } + // Ensure the coin selection has eliminated all negative values. + if (!value.empty(value.negatives(excessValue))) { + throw new Error( + "Unreachable! Somehow coin selection succeeded but still failed.", ); } } - if (this.body.inputs().values().length == 0) { - throw new Error( - "TxBuilder: resolved empty input set, cannot construct transaction!", - ); - } - // Ensure the coin selection has eliminated all negative values. - if (!value.empty(value.negatives(excessValue))) { - throw new Error( - "Unreachable! Somehow coin selection succeeded but still failed.", - ); - } // We first balance the native assets to avoid issues with the max value size being exceeded excessValue = this.balanceMultiAssetChange(excessValue); @@ -1718,6 +1724,13 @@ export class TxBuilder { changeOutput!.amount(), value.negate(excessValue), ); + + if (!coinSelection) { + throw new Error( + `Change output has more than inputs provide. Missing coin: ${excessDifference.coin()}. Missing multiassets: ${JSON.stringify(excessDifference.multiasset()?.entries(), (_, v) => (typeof v === "bigint" ? v.toString() : v))}`, + ); + } + // we must add more inputs, to cover the difference if (spareInputs.length == 0) { throw new Error("Tx builder could not satisfy coin selection"); diff --git a/packages/blaze-tx/tests/tx/tx.test.ts b/packages/blaze-tx/tests/tx/tx.test.ts index 2b996c6..05b09df 100644 --- a/packages/blaze-tx/tests/tx/tx.test.ts +++ b/packages/blaze-tx/tests/tx/tx.test.ts @@ -623,4 +623,41 @@ describe("Transaction Building", () => { expect(tx.body().fee().toString()).toEqual("475794"); }); + + it("should not use coin selection when set to false", async () => { + // $hosky + const testAddress = Address.fromBech32( + "addr1q86ylp637q7hv7a9r387nz8d9zdhem2v06pjyg75fvcmen3rg8t4q3f80r56p93xqzhcup0w7e5heq7lnayjzqau3dfs7yrls5", + ); + const tx = new TxBuilder(hardCodedProtocolParams) + .setNetworkId(NetworkId.Testnet) + .setChangeAddress(testAddress) + .addWithdrawal( + RewardAccount.fromCredential( + testAddress.getProps().paymentPart!, + NetworkId.Testnet, + ), + 100_000_000n, + ) + .payAssets(testAddress, value.makeValue(48_708_900n)); + + try { + await tx.complete(false); + } catch (e) { + expect((e as Error).message).toEqual( + "Change output has more than inputs provide. Missing coin: 49840323. Missing multiassets: undefined", + ); + } + + tx.addInput( + new TransactionUnspentOutput( + new TransactionInput(TransactionId("0".repeat(64)), 0n), + new TransactionOutput(testAddress, value.makeValue(50_000_000n)), + ), + ); + + const txComplete = await tx.complete(false); + expect(txComplete.body().inputs().values().length).toEqual(1); + expect(txComplete.body().outputs().length).toEqual(2); + }); });