Skip to content

Commit

Permalink
Allow Coin Selection Opt-Out (#237)
Browse files Browse the repository at this point in the history
* chore: allow coin selection opt-in or not for manual cases

* chore: fmt

* chore: changeset
  • Loading branch information
cjkoepke authored Feb 4, 2025
1 parent 74465e6 commit 1d7ba70
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 45 deletions.
5 changes: 5 additions & 0 deletions .changeset/metal-vans-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@blaze-cardano/tx": patch
---

Added the ability to opt-out of coin selection on complete.
103 changes: 58 additions & 45 deletions packages/blaze-tx/src/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1528,7 +1528,7 @@ export class TxBuilder {
* or if balancing the change output fails.
* @returns {Promise<Transaction>} A new Transaction object with all components set and ready for submission.
*/
async complete(): Promise<Transaction> {
async complete(coinSelection: boolean = true): Promise<Transaction> {
// Execute pre-complete hooks
if (this.preCompleteHooks && this.preCompleteHooks.length > 0) {
for (const hook of this.preCompleteHooks) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
37 changes: 37 additions & 0 deletions packages/blaze-tx/tests/tx/tx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

0 comments on commit 1d7ba70

Please sign in to comment.