diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8260b1cde..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:prettier.io)", - "WebFetch(domain:code.visualstudio.com)", - "WebFetch(domain:github.com)" - ], - "deny": [], - "ask": [] - } -} \ No newline at end of file diff --git a/packages/mesh-transaction/src/index.ts b/packages/mesh-transaction/src/index.ts index bb9ee5e08..414c7fae8 100644 --- a/packages/mesh-transaction/src/index.ts +++ b/packages/mesh-transaction/src/index.ts @@ -3,4 +3,8 @@ export * from "./scripts"; export * from "./transaction"; export * from "./utils"; export * from "./tx-parser"; -export { LargestFirstInputSelector } from "./mesh-tx-builder/coin-selection"; +export { + CoinSelectionError, + CoverageFirstSelector, + LargestFirstInputSelector, +} from "./mesh-tx-builder/coin-selection"; diff --git a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/coverage-first-selector.ts b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/coverage-first-selector.ts new file mode 100644 index 000000000..620e63395 --- /dev/null +++ b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/coverage-first-selector.ts @@ -0,0 +1,661 @@ +import { Output, TxIn, TxOutput, UTxO } from "@meshsdk/common"; + +import { + BuilderCallbacks, + IInputSelector, + ImplicitValue, + TransactionPrototype, +} from "./coin-selection-interface"; + +const MAX_U64 = 18446744073709551615n; + +// Value utilities +type Value = Map; +type SelectionPhase = "coverage" | "magnitude" | "final"; + +/** + * Error thrown when coin selection fails at a specific phase. + * Provides detailed information about what was missing and what was available. + */ +export class CoinSelectionError extends Error { + public readonly phase: SelectionPhase; + public readonly deficit: Value; + public readonly availableUtxos: UTxO[]; + public readonly selectedUtxos: UTxO[]; + + constructor( + phase: SelectionPhase, + deficit: Value, + availableUtxos: UTxO[], + selectedUtxos: UTxO[], + ) { + const deficitSummary = Array.from(deficit.entries()) + .filter(([_, qty]) => qty > 0n) + .map(([unit, qty]) => `${unit}: ${qty}`) + .join(", "); + + super( + `Coin selection failed at ${phase} phase. ` + + `Deficit: {${deficitSummary || "none"}}. ` + + `Available UTxOs: ${availableUtxos.length}, Selected: ${selectedUtxos.length}.`, + ); + + this.name = "CoinSelectionError"; + this.phase = phase; + this.deficit = deficit; + this.availableUtxos = availableUtxos; + this.selectedUtxos = selectedUtxos; + } +} + +// ============================================================================ +// Value Utility Functions +// ============================================================================ + +const mergeValue = (a: Value, b: Value): Value => { + const merged: Value = new Map(a); + b.forEach((quantity, unit) => { + if (merged.has(unit)) { + const mergedQuantity = merged.get(unit)! + quantity; + if (mergedQuantity === 0n) { + merged.delete(unit); + } else { + merged.set(unit, mergedQuantity); + } + } else { + merged.set(unit, quantity); + } + }); + return merged; +}; + +const subValue = (a: Value, b: Value): Value => { + const subtracted: Value = new Map(a); + b.forEach((quantity, unit) => { + if (subtracted.has(unit)) { + const subtractedQuantity = subtracted.get(unit)! - quantity; + if (subtractedQuantity === 0n) { + subtracted.delete(unit); + } else { + subtracted.set(unit, subtractedQuantity); + } + } else { + subtracted.set(unit, -quantity); + } + }); + return subtracted; +}; + +const valueAllPositive = (value: Value): boolean => { + return Array.from(value.values()).every((v) => v >= 0n); +}; + +const assetsToValue = (assets: { unit: string; quantity: string }[]): Value => { + const value: Value = new Map(); + assets.forEach((asset) => { + const { unit, quantity } = asset; + if (value.has(unit)) { + value.set(unit, value.get(unit)! + BigInt(quantity)); + } else { + value.set(unit, BigInt(quantity)); + } + }); + return value; +}; + +const outputToValue = (output: Output): Value => { + const value: Value = new Map(); + const amount = output.amount; + amount.forEach((asset) => { + const { unit, quantity } = asset; + if (value.has(unit)) { + value.set(unit, value.get(unit)! + BigInt(quantity)); + } else { + value.set(unit, BigInt(quantity)); + } + }); + return value; +}; + +const txInToValue = (input: TxIn): Value => { + const amount = input.txIn.amount; + if (!amount) { + throw new Error( + `UTxO amount info is missing for ${input.txIn.txHash}#${input.txIn.txIndex}`, + ); + } + return assetsToValue(amount); +}; + +const implicitValueToValue = (implicitValue: ImplicitValue): Value => { + let value: Value = new Map(); + value.set("lovelace", 0n); + value.set( + "lovelace", + value.get("lovelace")! + BigInt(implicitValue.withdrawals), + ); + + value.set("lovelace", value.get("lovelace")! - BigInt(implicitValue.deposit)); + + value = mergeValue(value, assetsToValue(implicitValue.mint)); + + value.set( + "lovelace", + value.get("lovelace")! + BigInt(implicitValue.reclaimDeposit), + ); + return value; +}; + +const computeNetImplicitSelectionValues = ( + preselectedUtxos: TxIn[], + outputs: Output[], + implicitValue: ImplicitValue, +): Value => { + let outputValueMap: Value = new Map(); + outputs.forEach((output) => { + outputValueMap = mergeValue(outputValueMap, outputToValue(output)); + }); + + const implicitValueMap = implicitValueToValue(implicitValue); + + let inputValueMap: Value = new Map(); + preselectedUtxos.forEach((input) => { + inputValueMap = mergeValue(inputValueMap, txInToValue(input)); + }); + + let requiredValue: Value = new Map(); + requiredValue = mergeValue(requiredValue, outputValueMap); + requiredValue = subValue(requiredValue, inputValueMap); + requiredValue = subValue(requiredValue, implicitValueMap); + + return requiredValue; +}; + +// ============================================================================ +// Coverage-First Helper Functions +// ============================================================================ + +/** + * Count distinct asset types with positive quantity in a Value. + */ +const countAssetTypes = (value: Value): number => { + let count = 0; + value.forEach((qty) => { + if (qty > 0n) count++; + }); + return count; +}; + +/** + * Filter to only positive quantities (the deficit). + */ +const filterPositives = (value: Value): Value => { + const result: Value = new Map(); + value.forEach((qty, unit) => { + if (qty > 0n) { + result.set(unit, qty); + } + }); + return result; +}; + +/** + * Count overlapping assets between two Values (both must have positive quantity). + */ +const countIntersection = (a: Value, b: Value): number => { + let count = 0; + a.forEach((qty, unit) => { + if (qty > 0n && b.has(unit) && b.get(unit)! > 0n) { + count++; + } + }); + return count; +}; + +/** + * Check if Value has no positive quantities (deficit is satisfied). + */ +const isDeficitSatisfied = (value: Value): boolean => { + return Array.from(value.values()).every((v) => v <= 0n); +}; + +/** + * Get first asset unit with positive quantity. + */ +const getFirstPositiveAsset = (value: Value): string | undefined => { + for (const [unit, qty] of value.entries()) { + if (qty > 0n) return unit; + } + return undefined; +}; + +// ============================================================================ +// Phase Result Type +// ============================================================================ + +interface PhaseResult { + selectedUtxos: UTxO[]; + remainingUtxos: UTxO[]; + accumulatedValue: Value; +} + +// ============================================================================ +// CoverageFirstSelector Implementation +// ============================================================================ + +/** + * CoverageFirstSelector implements a two-phase coin selection algorithm. + * + * Phase 1 (Coverage): Maximizes asset type coverage by selecting UTxOs that + * reduce the number of distinct asset types still needed. Uses a rating formula: + * rating = (typesReduced) + (intersection / 10) + * + * Phase 2 (Magnitude): Once coverage is optimized (≤1 asset type remaining), + * selects UTxOs by magnitude - picking the UTxO with the maximum quantity + * of the target asset. + * + * This approach typically results in fewer UTxOs selected compared to + * largest-first when dealing with multi-asset transactions. + */ +export class CoverageFirstSelector implements IInputSelector { + private readonly maxIterations: number; + + constructor(options?: { maxIterations?: number }) { + this.maxIterations = options?.maxIterations ?? 30; + } + + /** + * Phase 1: Coverage Phase + * Select UTxOs that maximize asset type coverage reduction. + */ + private coveragePhase(available: UTxO[], deficit: Value): PhaseResult { + const selectedUtxos: UTxO[] = []; + let remainingUtxos = [...available]; + let accumulatedValue: Value = new Map(); + + for (let i = 0; i < this.maxIterations; i++) { + // Calculate current deficit + const currentDeficit = filterPositives(subValue(deficit, accumulatedValue)); + const assetTypesNeeded = countAssetTypes(currentDeficit); + + // Exit coverage phase when ≤1 asset type remains + if (assetTypesNeeded <= 1) { + break; + } + + // Find UTxO with best rating + let bestUtxo: UTxO | null = null; + let bestRating = -Infinity; + let bestIndex = -1; + + for (let j = 0; j < remainingUtxos.length; j++) { + const utxo = remainingUtxos[j]!; + const utxoValue = assetsToValue(utxo.output.amount); + + // Calculate what deficit would look like after adding this UTxO + const newAccumulated = mergeValue(accumulatedValue, utxoValue); + const newDeficit = filterPositives(subValue(deficit, newAccumulated)); + const newAssetTypes = countAssetTypes(newDeficit); + + // Rating: how many asset types we reduce + intersection bonus + const typesReduced = assetTypesNeeded - newAssetTypes; + const intersection = countIntersection(currentDeficit, utxoValue); + const rating = typesReduced + intersection / 10; + + if (rating > bestRating) { + bestRating = rating; + bestUtxo = utxo; + bestIndex = j; + } + } + + // If no UTxO improves our situation, throw error + if (bestUtxo === null || bestRating <= 0) { + throw new CoinSelectionError( + "coverage", + currentDeficit, + remainingUtxos, + selectedUtxos, + ); + } + + // Select the best UTxO + selectedUtxos.push(bestUtxo); + remainingUtxos.splice(bestIndex, 1); + accumulatedValue = mergeValue( + accumulatedValue, + assetsToValue(bestUtxo.output.amount), + ); + } + + return { selectedUtxos, remainingUtxos, accumulatedValue }; + } + + /** + * Phase 2: Magnitude Phase + * Select UTxOs by magnitude for remaining deficit. + */ + private magnitudePhase( + available: UTxO[], + deficit: Value, + initialAccumulated: Value, + initialSelected: UTxO[], + ): PhaseResult { + const selectedUtxos = [...initialSelected]; + let remainingUtxos = [...available]; + let accumulatedValue = new Map(initialAccumulated); + + for (let i = 0; i < this.maxIterations; i++) { + // Calculate current deficit + const currentDeficit = filterPositives(subValue(deficit, accumulatedValue)); + + // Exit when deficit is satisfied + if (isDeficitSatisfied(currentDeficit)) { + break; + } + + // Pick first positive asset as target + const targetAsset = getFirstPositiveAsset(currentDeficit); + if (!targetAsset) { + break; + } + + // Find UTxO with maximum of target asset + let bestUtxo: UTxO | null = null; + let bestAmount = 0n; + let bestIndex = -1; + + for (let j = 0; j < remainingUtxos.length; j++) { + const utxo = remainingUtxos[j]!; + const utxoValue = assetsToValue(utxo.output.amount); + const amount = utxoValue.get(targetAsset) ?? 0n; + + if (amount > bestAmount) { + bestAmount = amount; + bestUtxo = utxo; + bestIndex = j; + } + } + + // If no UTxO has the target asset, throw error + if (bestUtxo === null || bestAmount === 0n) { + throw new CoinSelectionError( + "magnitude", + currentDeficit, + remainingUtxos, + selectedUtxos, + ); + } + + // Select the best UTxO + selectedUtxos.push(bestUtxo); + remainingUtxos.splice(bestIndex, 1); + accumulatedValue = mergeValue( + accumulatedValue, + assetsToValue(bestUtxo.output.amount), + ); + } + + return { selectedUtxos, remainingUtxos, accumulatedValue }; + } + + /** + * Compute change outputs, splitting if token bundle exceeds size limit. + */ + private computeChangeOutputs( + remainingValue: Value, + changeAddress: string, + constraints: BuilderCallbacks, + ): { changeOutputs: TxOutput[]; valueFulfilled: boolean } { + let lovelaceAvailable = remainingValue.get("lovelace") || 0n; + let valueFulfilled = true; + const valueAssets = remainingValue + .entries() + .filter(([_, quantity]) => quantity > 0n) + .map(([unit, quantity]) => ({ + unit, + quantity: String(quantity), + })) + .toArray(); + const changeOutputs: TxOutput[] = []; + let currentBundle: { unit: string; quantity: string }[] = [ + { + unit: "lovelace", + quantity: String(0), + }, + ]; + if (constraints.tokenBundleSizeExceedsLimit(valueAssets)) { + // if the value assets exceed the token bundle size limit, + // we need to split them into multiple change outputs + const tokenAssets = valueAssets.filter( + (asset) => asset.unit !== "lovelace", + ); + for (const tokenAsset of tokenAssets) { + currentBundle.push(tokenAsset); + if (constraints.tokenBundleSizeExceedsLimit(currentBundle)) { + const currentToken = currentBundle.pop(); + const minUtxo = constraints.computeMinimumCoinQuantity({ + address: changeAddress, + amount: currentBundle, + }); + // lovelace value should be guaranteed to be the first element + currentBundle[0]!.quantity = minUtxo.toString(); + changeOutputs.push({ + address: changeAddress, + amount: currentBundle, + }); + currentBundle = [ + { + unit: "lovelace", + quantity: String(0), + }, + currentToken!, + ]; + lovelaceAvailable -= minUtxo; + } + } + // Handle final token bundle + if (currentBundle.length > 0) { + const minUtxo = constraints.computeMinimumCoinQuantity({ + address: changeAddress, + amount: currentBundle, + }); + currentBundle[0]!.quantity = minUtxo.toString(); + changeOutputs.push({ + address: changeAddress, + amount: currentBundle, + }); + lovelaceAvailable -= minUtxo; + } + + // If there is lovelace remaining, just put it in the last output + if (lovelaceAvailable > 0n) { + const finalOutput = changeOutputs[changeOutputs.length - 1]!; + const finalOutputLovelaces = finalOutput.amount.find( + (asset) => asset.unit === "lovelace", + ); + if (finalOutputLovelaces) { + finalOutputLovelaces.quantity = String( + BigInt(lovelaceAvailable) + BigInt(finalOutputLovelaces.quantity), + ); + } + } else { + // If there's no lovelace remaining, then we return the change outputs + // each with min utxo value, and we set valueFulfilled to false to indicate this + valueFulfilled = false; + } + } else { + changeOutputs.push({ + address: changeAddress, + amount: valueAssets, + }); + if (lovelaceAvailable < 0n) { + // lovelaceAvailable being negative means that we didn't have enough + // lovelaces to cover fees, we set valueFulfilled to false + valueFulfilled = false; + } + } + + return { changeOutputs, valueFulfilled }; + } + + /** + * Expand UTxOs until fees are covered, iteratively computing fee and change. + */ + private async expandForFees( + selectedUtxos: UTxO[], + remainingUtxos: UTxO[], + accumulatedValue: Value, + requiredValue: Value, + changeAddress: string, + constraints: BuilderCallbacks, + ): Promise<{ + finalSelectedUtxos: Set; + fee: bigint; + changeOutputs: TxOutput[]; + }> { + const finalSelected = new Set(selectedUtxos); + let remainingValue = subValue(accumulatedValue, requiredValue); + let available = [...remainingUtxos]; + + // Sort by lovelace descending for fee expansion + available.sort((a, b) => { + const aLovelace = BigInt( + a.output.amount.find((x) => x.unit === "lovelace")?.quantity || "0", + ); + const bLovelace = BigInt( + b.output.amount.find((x) => x.unit === "lovelace")?.quantity || "0", + ); + return Number(bLovelace - aLovelace); + }); + + let computedCost = 0n; + let computedChangeOutputs: TxOutput[] = []; + let numberOfIterations = 3; + + for (let i = 0; i < numberOfIterations; i++) { + const remainingValueWithCost = subValue( + remainingValue, + new Map([["lovelace", computedCost]]), + ); + const { changeOutputs, valueFulfilled } = this.computeChangeOutputs( + remainingValueWithCost, + changeAddress, + constraints, + ); + computedChangeOutputs = changeOutputs; + + if (!valueFulfilled) { + if (available.length === 0) { + throw new CoinSelectionError( + "final", + filterPositives(subValue(requiredValue, accumulatedValue)), + [], + Array.from(finalSelected), + ); + } else { + const selectedUtxo = available.shift(); + if (selectedUtxo) { + finalSelected.add(selectedUtxo); + const utxoValue = assetsToValue(selectedUtxo.output.amount); + remainingValue = mergeValue(remainingValue, utxoValue); + numberOfIterations++; // Increase iterations since we added a new UTxO + } + } + } + + if (i < numberOfIterations - 1) { + // Recompute the cost + computedCost = ( + await constraints.computeMinimumCost({ + newInputs: finalSelected, + newOutputs: new Set(), + change: changeOutputs, + fee: MAX_U64, + }) + ).fee; + } + } + + return { + finalSelectedUtxos: finalSelected, + fee: computedCost, + changeOutputs: computedChangeOutputs, + }; + } + + /** + * Main selection method implementing the two-phase algorithm. + */ + async select( + preselectedUtxos: TxIn[], + outputs: Output[], + implicitValue: ImplicitValue, + utxos: UTxO[], + changeAddress: string, + constraints: BuilderCallbacks, + ): Promise { + // Step 1: Compute required value + const requiredValue = computeNetImplicitSelectionValues( + preselectedUtxos, + outputs, + implicitValue, + ); + + // Step 2: Sanity check - total UTxO value >= required + let totalUtxoValue: Value = new Map(); + utxos.forEach((utxo) => { + totalUtxoValue = mergeValue(totalUtxoValue, assetsToValue(utxo.output.amount)); + }); + + const checkValue = subValue(totalUtxoValue, requiredValue); + if (!valueAllPositive(checkValue)) { + throw new CoinSelectionError( + "final", + filterPositives(subValue(requiredValue, totalUtxoValue)), + utxos, + [], + ); + } + + // Step 3: Coverage Phase + const coverageResult = this.coveragePhase(utxos, requiredValue); + + // Step 4: Magnitude Phase + const magnitudeResult = this.magnitudePhase( + coverageResult.remainingUtxos, + requiredValue, + coverageResult.accumulatedValue, + coverageResult.selectedUtxos, + ); + + // Step 5: Fee Expansion + const { finalSelectedUtxos, fee, changeOutputs } = await this.expandForFees( + magnitudeResult.selectedUtxos, + magnitudeResult.remainingUtxos, + magnitudeResult.accumulatedValue, + requiredValue, + changeAddress, + constraints, + ); + + // Step 6: Validate tx size + if ( + await constraints.maxSizeExceed({ + newInputs: finalSelectedUtxos, + newOutputs: new Set(), + change: changeOutputs, + fee: fee, + }) + ) { + throw new Error("Transaction size exceeds the maximum allowed size."); + } + + return { + newInputs: finalSelectedUtxos, + newOutputs: new Set(), + change: changeOutputs, + fee: fee, + }; + } +} diff --git a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/index.ts b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/index.ts index ea8f44d3d..81c8288de 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/index.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/index.ts @@ -3,11 +3,17 @@ import { CardanoSdkInputSelector, } from "./cardano-sdk-adapter"; import * as CoinSelectionInterface from "./coin-selection-interface"; +import { + CoinSelectionError, + CoverageFirstSelector, +} from "./coverage-first-selector"; import { LargestFirstInputSelector } from "./largest-first-selector"; export { BuilderCallbacksSdkBridge, CardanoSdkInputSelector, + CoinSelectionError, + CoverageFirstSelector, LargestFirstInputSelector, CoinSelectionInterface, }; diff --git a/packages/mesh-transaction/test/mesh-tx-builder/coin-selection/coverage-first.test.ts b/packages/mesh-transaction/test/mesh-tx-builder/coin-selection/coverage-first.test.ts new file mode 100644 index 000000000..a5509dc63 --- /dev/null +++ b/packages/mesh-transaction/test/mesh-tx-builder/coin-selection/coverage-first.test.ts @@ -0,0 +1,990 @@ +import { MeshValue, UTxO } from "@meshsdk/common"; +import { serializePlutusScript } from "@meshsdk/core"; +import { resolveScriptRef, Transaction, TxCBOR } from "@meshsdk/core-cst"; +import { OfflineFetcher } from "@meshsdk/provider"; +import { + CoinSelectionError, + CoverageFirstSelector, + MeshTxBuilder, +} from "@meshsdk/transaction"; + +import { + alwaysSucceedCbor, + alwaysSucceedHash, + baseAddress, + calculateInputMeshValue, + calculateMinLovelaceForTransactionOutput, + calculateOutputMeshValue, + mockTokenUnit, + txHash, +} from "../../test-util"; + +describe("MeshTxBuilder - coin selection - Coverage First", () => { + let txBuilder: MeshTxBuilder; + + beforeEach(() => { + const offlineFetcher = new OfflineFetcher(); + offlineFetcher.addUTxOs([ + { + input: { + txHash: txHash("tx3"), + outputIndex: 0, + }, + output: { + address: serializePlutusScript({ + code: alwaysSucceedCbor, + version: "V3", + }).address, + amount: [ + { + unit: "lovelace", + quantity: "100000000", + }, + ], + scriptHash: alwaysSucceedHash, + scriptRef: resolveScriptRef({ + code: alwaysSucceedCbor, + version: "V3", + }), + }, + }, + ]); + txBuilder = new MeshTxBuilder({ + fetcher: offlineFetcher, + selector: new CoverageFirstSelector(), + }); + }); + + // ============================================================================ + // A. Basic Selection (4 tests) + // ============================================================================ + + describe("A. Basic Selection", () => { + it("Simple lovelace transfer", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { + txHash: txHash("tx0"), + outputIndex: 0, + }, + output: { + address: baseAddress(0), + amount: [ + { + unit: "lovelace", + quantity: "5000000", + }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { + unit: "lovelace", + quantity: "2000000", + }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().outputs().length).toBeGreaterThanOrEqual(2); + }); + + it("Transaction balances correctly (inputs = outputs + fee)", async () => { + const inputValue = 10000000n; + const utxosForSelection: UTxO[] = [ + { + input: { + txHash: txHash("tx0"), + outputIndex: 0, + }, + output: { + address: baseAddress(0), + amount: [ + { + unit: "lovelace", + quantity: inputValue.toString(), + }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { + unit: "lovelace", + quantity: "2000000", + }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + const fee = cardanoTx.body().fee(); + const outputs = cardanoTx.body().outputs(); + const totalOutput = outputs.reduce((acc, output) => { + return acc + output.amount().coin(); + }, 0n); + expect(totalOutput + fee).toEqual(inputValue); + }); + + it("Handles minting", async () => { + const mintAsset = alwaysSucceedHash; + const inputValue = 10000000n; + const utxosForSelection: UTxO[] = [ + { + input: { + txHash: txHash("tx1"), + outputIndex: 0, + }, + output: { + address: baseAddress(1), + amount: [ + { + unit: "lovelace", + quantity: inputValue.toString(), + }, + ], + }, + }, + ]; + const tx = await txBuilder + .txOut(baseAddress(1), [ + { + unit: "lovelace", + quantity: "1000000", + }, + { + unit: mintAsset, + quantity: "1000", + }, + ]) + .mintPlutusScriptV3() + .mint("1000", alwaysSucceedHash, "") + .mintRedeemerValue("") + .mintTxInReference(txHash("tx3"), 0) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(1)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().outputs().length).toEqual(2); + const fee = cardanoTx.body().fee(); + const outputs = cardanoTx.body().outputs(); + const totalOutput = outputs.reduce((acc, output) => { + return acc + output.amount().coin(); + }, 0n); + expect(totalOutput + fee).toEqual(inputValue); + }); + + it("Handles multiple outputs", async () => { + const mintAsset = alwaysSucceedHash; + const inputValue1 = 10000000n; + const inputValue2 = 100n; + const inputValue3 = 30000n; + const inputValue4 = 30000n; + + const utxosForSelection: UTxO[] = [ + { + input: { + txHash: txHash("tx10"), + outputIndex: 0, + }, + output: { + address: baseAddress(1), + amount: [ + { + unit: "lovelace", + quantity: inputValue1.toString(), + }, + ], + }, + }, + { + input: { + txHash: txHash("tx9"), + outputIndex: 0, + }, + output: { + address: baseAddress(1), + amount: [ + { + unit: "lovelace", + quantity: inputValue2.toString(), + }, + { + unit: mockTokenUnit(1), + quantity: "100", + }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txIn( + txHash("tx7"), + 0, + [ + { + unit: "lovelace", + quantity: inputValue3.toString(), + }, + ], + baseAddress(1), + 0, + ) + .txIn( + txHash("tx6"), + 0, + [ + { + unit: "lovelace", + quantity: inputValue4.toString(), + }, + ], + baseAddress(2), + 0, + ) + .txOut(baseAddress(0), [ + { + unit: "lovelace", + quantity: "1000000", + }, + { + unit: mintAsset, + quantity: "1000", + }, + ]) + .txOut(baseAddress(5), [ + { + unit: "lovelace", + quantity: "1000000", + }, + { + unit: mockTokenUnit(1), + quantity: "100", + }, + ]) + .mintPlutusScriptV3() + .mint("1000", alwaysSucceedHash, "") + .mintRedeemerValue("") + .mintTxInReference(txHash("tx3"), 0) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(1)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toEqual(4); + expect(cardanoTx.body().outputs().length).toEqual(3); + + const fee = cardanoTx.body().fee(); + const outputs = cardanoTx.body().outputs(); + const totalOutput = outputs.reduce((acc, output) => { + return acc + output.amount().coin(); + }, 0n); + expect(totalOutput + fee).toEqual( + inputValue1 + inputValue2 + inputValue3 + inputValue4, + ); + }); + }); + + // ============================================================================ + // B. Coverage Phase Behavior (5 tests) + // ============================================================================ + + describe("B. Coverage Phase Behavior", () => { + it("Prefers UTxO covering multiple asset types over single-asset UTxOs", async () => { + // UTxO with multiple tokens should be preferred + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("single1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(1), quantity: "1000000" }, + ], + }, + }, + { + input: { txHash: txHash("single2"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(2), quantity: "1000000" }, + ], + }, + }, + { + input: { txHash: txHash("multi"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "3000000" }, + { unit: mockTokenUnit(1), quantity: "500000" }, + { unit: mockTokenUnit(2), quantity: "500000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "400000" }, + { unit: mockTokenUnit(2), quantity: "400000" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + // Should select the multi-asset UTxO (covers both tokens) rather than two single-asset UTxOs + expect(cardanoTx.body().inputs().size()).toBeLessThanOrEqual(2); + }); + + it("Uses intersection as tiebreaker when coverage reduction is equal", async () => { + // Both UTxOs reduce coverage by same amount, but one has more intersection with deficit + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(1), quantity: "1000" }, + ], + }, + }, + { + input: { txHash: txHash("utxo2"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(1), quantity: "2000" }, + { unit: mockTokenUnit(2), quantity: "1000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "500" }, + { unit: mockTokenUnit(2), quantity: "500" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().outputs().length).toBeGreaterThanOrEqual(2); + }); + + it("Exits coverage phase when ≤1 asset type remains", async () => { + // After selecting UTxO covering multiple assets, only lovelace should remain + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo0"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(1), quantity: "5000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "1000" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toEqual(1); + }); + + it("Rating formula: (typesReduced + intersection/10) works correctly", async () => { + // UTxO that reduces 2 types (rating 2) should be preferred over + // UTxO that reduces 1 type with high intersection (rating ~1.3) + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("high-intersect"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(1), quantity: "10000" }, + { unit: mockTokenUnit(2), quantity: "10000" }, + { unit: mockTokenUnit(3), quantity: "10000" }, + ], + }, + }, + { + input: { txHash: txHash("two-types"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(4), quantity: "5000" }, + { unit: mockTokenUnit(5), quantity: "5000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "1000" }, + { unit: mockTokenUnit(2), quantity: "1000" }, + { unit: mockTokenUnit(3), quantity: "1000" }, + { unit: mockTokenUnit(4), quantity: "1000" }, + { unit: mockTokenUnit(5), quantity: "1000" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toEqual(2); + }); + + it("Handles 10+ different token types efficiently", async () => { + const tokens: { unit: string; quantity: string }[] = []; + for (let i = 1; i <= 12; i++) { + tokens.push({ unit: mockTokenUnit(i), quantity: "1000" }); + } + + // Create UTxOs that each have a few tokens + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("batch1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(1), quantity: "5000" }, + { unit: mockTokenUnit(2), quantity: "5000" }, + { unit: mockTokenUnit(3), quantity: "5000" }, + { unit: mockTokenUnit(4), quantity: "5000" }, + ], + }, + }, + { + input: { txHash: txHash("batch2"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(5), quantity: "5000" }, + { unit: mockTokenUnit(6), quantity: "5000" }, + { unit: mockTokenUnit(7), quantity: "5000" }, + { unit: mockTokenUnit(8), quantity: "5000" }, + ], + }, + }, + { + input: { txHash: txHash("batch3"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(9), quantity: "5000" }, + { unit: mockTokenUnit(10), quantity: "5000" }, + { unit: mockTokenUnit(11), quantity: "5000" }, + { unit: mockTokenUnit(12), quantity: "5000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [{ unit: "lovelace", quantity: "2000000" }, ...tokens]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toEqual(3); + }); + }); + + // ============================================================================ + // C. Magnitude Phase Behavior (3 tests) + // ============================================================================ + + describe("C. Magnitude Phase Behavior", () => { + it("Picks UTxO with maximum of target asset", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("small"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "2000000" }, + { unit: mockTokenUnit(1), quantity: "100" }, + ], + }, + }, + { + input: { txHash: txHash("large"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "2000000" }, + { unit: mockTokenUnit(1), quantity: "10000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "5000" }, + { unit: "lovelace", quantity: "1000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + // Should have selected the large UTxO first + expect(cardanoTx.body().inputs().size()).toEqual(1); + }); + + it("Processes assets in order until all satisfied", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(1), quantity: "10000" }, + ], + }, + }, + { + input: { txHash: txHash("utxo2"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(2), quantity: "10000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "5000" }, + { unit: mockTokenUnit(2), quantity: "5000" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toEqual(2); + }); + + it("Transitions smoothly from coverage to magnitude phase", async () => { + // First UTxO covers multiple types, second needed for magnitude + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("coverage"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "2000000" }, + { unit: mockTokenUnit(1), quantity: "100" }, + { unit: mockTokenUnit(2), quantity: "100" }, + ], + }, + }, + { + input: { txHash: txHash("magnitude"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(1), quantity: "10000" }, + ], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "5000" }, + { unit: mockTokenUnit(2), quantity: "50" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toEqual(2); + }); + }); + + // ============================================================================ + // D. Error Handling (4 tests) + // ============================================================================ + + describe("D. Error Handling", () => { + it("Throws CoinSelectionError with phase='coverage' when stuck", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: mockTokenUnit(1), quantity: "1000" }, + ], + }, + }, + ]; + + await expect( + txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "500" }, + { unit: mockTokenUnit(2), quantity: "500" }, // This token doesn't exist + { unit: mockTokenUnit(3), quantity: "500" }, // This token doesn't exist + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(), + ).rejects.toThrow(); + }); + + it("Throws CoinSelectionError with phase='magnitude' when asset unavailable", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "5000000" }, + ], + }, + }, + ]; + + await expect( + txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(99), quantity: "1000" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(), + ).rejects.toThrow(); + }); + + it("Throws CoinSelectionError with phase='final' when insufficient total", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: "lovelace", quantity: "1000000" }, + { unit: mockTokenUnit(1), quantity: "100" }, + ], + }, + }, + ]; + + await expect( + txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "500" }, // More than available + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(), + ).rejects.toThrow(); + }); + + it("Error includes helpful deficit summary", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("utxo1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [{ unit: "lovelace", quantity: "1000000" }], + }, + }, + ]; + + try { + await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "1000" }, + { unit: "lovelace", quantity: "2000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + fail("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Deficit"); + } + }); + }); + + // ============================================================================ + // E. Edge Cases (6 tests) + // ============================================================================ + + describe("E. Edge Cases", () => { + it("Minimum ADA requirement for change output", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("tx0"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: mockTokenUnit(1), quantity: "1000000" }, + { unit: "lovelace", quantity: "50000000" }, + ], + }, + }, + { + input: { txHash: txHash("tx1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [{ unit: "lovelace", quantity: "2000000" }], + }, + }, + ]; + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "500000" }, + { unit: "lovelace", quantity: "50000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + const outputs = cardanoTx.body().outputs(); + for (const output of outputs) { + expect(output.amount().coin()).toBeGreaterThanOrEqual( + calculateMinLovelaceForTransactionOutput(output, 4310n), + ); + } + }); + + it("Consolidates fragmented UTxOs correctly", async () => { + const utxosForSelection: UTxO[] = Array.from({ length: 10 }, (_, i) => ({ + input: { txHash: txHash(`tx${i}`), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [{ unit: "lovelace", quantity: "1000000" }], + }, + })); + + const fetcher = new OfflineFetcher(); + fetcher.addUTxOs(utxosForSelection); + + const tx = await txBuilder + .txOut(baseAddress(1), [{ unit: "lovelace", quantity: "5000000" }]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().inputs().size()).toBeGreaterThan(1); + + const outputValue = calculateOutputMeshValue(tx); + outputValue.addAsset({ + unit: "lovelace", + quantity: cardanoTx.body().fee().toString(), + }); + const inputValue = await calculateInputMeshValue(tx, fetcher); + expect(inputValue.eq(outputValue)).toBe(true); + }); + + it("Splits large change outputs exceeding maxValSize", async () => { + const utxosForSelection: UTxO[] = []; + const tokens = []; + for (let j = 0; j < 400; j++) { + tokens.push({ unit: mockTokenUnit(j), quantity: "1000" }); + } + utxosForSelection.push({ + input: { txHash: txHash("tx1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [{ unit: "lovelace", quantity: "1000000000000" }, ...tokens], + }, + }); + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(1), quantity: "1000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + // Should have multiple change outputs due to token bundle size + expect(cardanoTx.body().outputs().length).toBeGreaterThanOrEqual(3); + }); + + it("Handles mixed lovelace + tokens", async () => { + const utxosForSelection: UTxO[] = [ + { + input: { txHash: txHash("tx0"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: mockTokenUnit(1), quantity: "1000000" }, + { unit: "lovelace", quantity: "3000000" }, + ], + }, + }, + { + input: { txHash: txHash("tx1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: mockTokenUnit(2), quantity: "2000000" }, + { unit: "lovelace", quantity: "2000000" }, + ], + }, + }, + { + input: { txHash: txHash("tx2"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [ + { unit: mockTokenUnit(3), quantity: "3000000" }, + { unit: "lovelace", quantity: "2000000" }, + ], + }, + }, + ]; + + const fetcher = new OfflineFetcher(); + fetcher.addUTxOs(utxosForSelection); + + const tx = await txBuilder + .txOut(baseAddress(1), [ + { unit: mockTokenUnit(1), quantity: "500000" }, + { unit: mockTokenUnit(3), quantity: "1000000" }, + { unit: "lovelace", quantity: "3000000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(2)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().outputs().length).toBeGreaterThan(1); + + const outputValue = calculateOutputMeshValue(tx); + outputValue.addAsset({ + unit: "lovelace", + quantity: cardanoTx.body().fee().toString(), + }); + const inputValue = await calculateInputMeshValue(tx, fetcher); + expect(inputValue.eq(outputValue)).toBe(true); + }); + + it("Works with 10,000 UTxOs (performance)", async () => { + const utxosForSelection: UTxO[] = []; + const tokensCount = 135; + for (let i = 0; i < 10000; i++) { + const tokens = []; + for (let j = 0; j < 50; j++) { + tokens.push({ + unit: mockTokenUnit((i + j) % tokensCount), + quantity: "1000", + }); + } + utxosForSelection.push({ + input: { txHash: txHash(`tx${i}`), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [{ unit: "lovelace", quantity: "4000000" }, ...tokens], + }, + }); + } + + const fetcher = new OfflineFetcher(); + fetcher.addUTxOs(utxosForSelection); + + const tx = await txBuilder + .txOut(baseAddress(1), [{ unit: "lovelace", quantity: "10000000" }]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(); + + const cardanoTx = Transaction.fromCbor(TxCBOR(tx)); + expect(cardanoTx.body().outputs().length).toBeGreaterThanOrEqual(2); + + const outputValue = calculateOutputMeshValue(tx); + outputValue.addAsset({ + unit: "lovelace", + quantity: cardanoTx.body().fee().toString(), + }); + const inputValue = await calculateInputMeshValue(tx, fetcher); + expect(inputValue.eq(outputValue)).toBe(true); + }); + + it("Transaction size limit enforcement", async () => { + const utxosForSelection: UTxO[] = []; + const tokens = []; + for (let j = 0; j < 1000; j++) { + tokens.push({ unit: mockTokenUnit(j), quantity: "1000" }); + } + utxosForSelection.push({ + input: { txHash: txHash("tx1"), outputIndex: 0 }, + output: { + address: baseAddress(0), + amount: [{ unit: "lovelace", quantity: "1000000000000" }, ...tokens], + }, + }); + + await expect( + txBuilder + .txOut(baseAddress(1), [ + { unit: "lovelace", quantity: "10000000" }, + { unit: mockTokenUnit(1), quantity: "1000" }, + ]) + .selectUtxosFrom(utxosForSelection) + .changeAddress(baseAddress(0)) + .complete(), + ).rejects.toThrow(); + }); + }); +});