diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 2e22c01b..d3d4f550 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -669,7 +669,7 @@ export class BuildOptionsTag extends Context.Tag("BuildOptions") +export type ProgramStep = Effect.Effect // ============================================================================ // Voter Key diff --git a/packages/evolution/src/sdk/builders/operations/Pay.ts b/packages/evolution/src/sdk/builders/operations/Pay.ts index e0ae442c..6de8201d 100644 --- a/packages/evolution/src/sdk/builders/operations/Pay.ts +++ b/packages/evolution/src/sdk/builders/operations/Pay.ts @@ -8,18 +8,26 @@ import { Effect, Ref } from "effect" import * as CoreAssets from "../../../Assets.js" -import { makeTxOutput } from "../internal/txBuilder.js" -import { TxContext } from "../TransactionBuilder.js" +import { calculateMinimumUtxoLovelace, makeTxOutput } from "../internal/txBuilder.js" +import { ProtocolParametersTag, TransactionBuilderError, TxContext } from "../TransactionBuilder.js" import type { PayToAddressParams } from "./Operations.js" /** * Creates a ProgramStep for payToAddress operation. * Creates a UTxO output and tracks assets for balancing. * + * Automatically enforces the minimum UTxO lovelace requirement: if the caller + * provides lovelace below the protocol-parameter minimum (or omits it entirely), + * the output is silently bumped up to the required minimum. This mirrors the + * behaviour of the change-creation phase and prevents on-chain rejections due + * to dust outputs. + * * Implementation: - * 1. Creates UTxO output from parameters using helper - * 2. Adds output to state.outputs array - * 3. Updates totalOutputAssets for balancing + * 1. Calculates the minimum lovelace for the requested output + * 2. Uses the higher of the specified and required lovelace + * 3. Creates the UTxO output with the effective assets + * 4. Adds output to state.outputs array + * 5. Updates totalOutputAssets for balancing (using the effective amount) * * @since 2.0.0 * @category programs @@ -27,19 +35,37 @@ import type { PayToAddressParams } from "./Operations.js" export const createPayToAddressProgram = (params: PayToAddressParams) => Effect.gen(function* () { const ctx = yield* TxContext + const protocolParams = yield* ProtocolParametersTag - // 1. Create Core TransactionOutput from params - const output = makeTxOutput({ + // 1. Calculate the minimum lovelace required for this output + const minLovelace = yield* calculateMinimumUtxoLovelace({ address: params.address, assets: params.assets, datum: params.datum, - scriptRef: params.script // Script is now directly compatible with UTxO.scriptRef + scriptRef: params.script, + coinsPerUtxoByte: protocolParams.coinsPerUtxoByte + }) + + // 2. Enforce minimum: silently bump lovelace up if below minimum + const specifiedLovelace = CoreAssets.lovelaceOf(params.assets) + const effectiveLovelace = specifiedLovelace < minLovelace ? minLovelace : specifiedLovelace + const effectiveAssets = + effectiveLovelace !== specifiedLovelace + ? CoreAssets.withLovelace(params.assets, effectiveLovelace) + : params.assets + + // 3. Create Core TransactionOutput from effective params + const output = makeTxOutput({ + address: params.address, + assets: effectiveAssets, + datum: params.datum, + scriptRef: params.script }) - // 2. Add output to state + // 4. Add output to state (totalOutputAssets uses effective lovelace for accurate balancing) yield* Ref.update(ctx, (state) => ({ ...state, outputs: [...state.outputs, output], - totalOutputAssets: CoreAssets.merge(state.totalOutputAssets, params.assets) + totalOutputAssets: CoreAssets.merge(state.totalOutputAssets, effectiveAssets) })) - }) + }) satisfies Effect.Effect diff --git a/packages/evolution/test/TxBuilder.PayMinUtxo.test.ts b/packages/evolution/test/TxBuilder.PayMinUtxo.test.ts new file mode 100644 index 00000000..811b7764 --- /dev/null +++ b/packages/evolution/test/TxBuilder.PayMinUtxo.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import * as Address from "../src/Address.js" +import * as CoreAssets from "../src/Assets.js" +import { calculateMinimumUtxoLovelace } from "../src/sdk/builders/internal/txBuilder.js" +import { makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" +import { mainnet } from "../src/sdk/client/index.js" +import { createCoreTestUtxo } from "./utils/utxo-helpers.js" + +const PROTOCOL_PARAMS = { + minFeeCoefficient: 44n, + minFeeConstant: 155_381n, + coinsPerUtxoByte: 4_310n, + maxTxSize: 16_384 +} + +const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" +const RECEIVER_ADDRESS = + "addr_test1qpw0djgj0x59ngrjvqthn7enhvruxnsavsw5th63la3mjel3tkc974sr23jmlzgq5zda4gtv8k9cy38756r9y3qgmkqqjz6aa7" + +const expectedMinLovelace = (assets: CoreAssets.Assets) => + Effect.runPromise( + calculateMinimumUtxoLovelace({ + address: Address.fromBech32(RECEIVER_ADDRESS), + assets, + coinsPerUtxoByte: PROTOCOL_PARAMS.coinsPerUtxoByte + }) + ) + +const buildAndGetFirstOutput = async ( + receiverAssets: CoreAssets.Assets, + walletLovelace = 10_000_000n, + walletNativeAssets?: Record +) => { + const signBuilder = await makeTxBuilder({ chain: mainnet }) + .payToAddress({ + address: Address.fromBech32(RECEIVER_ADDRESS), + assets: receiverAssets + }) + .build({ + changeAddress: Address.fromBech32(CHANGE_ADDRESS), + availableUtxos: [ + createCoreTestUtxo({ + transactionId: "a".repeat(64), + index: 0n, + address: CHANGE_ADDRESS, + lovelace: walletLovelace, + nativeAssets: walletNativeAssets + }) + ], + protocolParameters: PROTOCOL_PARAMS + }) + + const tx = await signBuilder.toTransaction() + return tx.body.outputs[0] +} + +describe("TxBuilder – payToAddress auto min-ADA enforcement", () => { + it("bumps zero lovelace up to the protocol minimum", async () => { + const requested = CoreAssets.fromLovelace(0n) + const minLovelace = await expectedMinLovelace(requested) + + const output = await buildAndGetFirstOutput(requested) + + expect(output.assets.lovelace).toBe(minLovelace) + expect(minLovelace).toBeGreaterThan(0n) + }) + + it("bumps sub-minimum lovelace up to the protocol minimum", async () => { + const requested = CoreAssets.fromLovelace(100n) + const minLovelace = await expectedMinLovelace(requested) + + const output = await buildAndGetFirstOutput(requested) + + expect(output.assets.lovelace).toBe(minLovelace) + expect(output.assets.lovelace).toBeGreaterThan(100n) + }) + + it("leaves sufficient lovelace unchanged", async () => { + const SUFFICIENT = 2_000_000n + const requested = CoreAssets.fromLovelace(SUFFICIENT) + const minLovelace = await expectedMinLovelace(requested) + expect(SUFFICIENT).toBeGreaterThanOrEqual(minLovelace) + + const output = await buildAndGetFirstOutput(requested) + + expect(output.assets.lovelace).toBe(SUFFICIENT) + }) + + it("leaves generous lovelace unchanged", async () => { + const output = await buildAndGetFirstOutput(CoreAssets.fromLovelace(5_000_000n)) + + expect(output.assets.lovelace).toBe(5_000_000n) + }) + + it("bumps native-token output with zero lovelace to the token-aware minimum", async () => { + const POLICY_HEX = "aa".repeat(28) + const ASSET_NAME_HEX = "546f6b656e41" + const TOKEN_UNIT = `${POLICY_HEX}${ASSET_NAME_HEX}` + + const requestedAssets = CoreAssets.fromHexStrings(POLICY_HEX, ASSET_NAME_HEX, 500n, 0n) + const minLovelace = await expectedMinLovelace(requestedAssets) + const adaOnlyMin = await expectedMinLovelace(CoreAssets.fromLovelace(0n)) + + const output = await buildAndGetFirstOutput(requestedAssets, 10_000_000n, { + [TOKEN_UNIT]: 1_000n + }) + + expect(output.assets.lovelace).toBe(minLovelace) + expect(minLovelace).toBeGreaterThan(adaOnlyMin) + }) +}) diff --git a/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts b/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts index 2791ca6a..23fbcfc9 100644 --- a/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts +++ b/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts @@ -86,16 +86,15 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { expect(tx.body.inputs).toHaveLength(2) // Initial + reselected UTxO expect(tx.body.outputs).toHaveLength(5) // 1 payment + 4 change (3 token bundles + 1 ADA) - // Verify payment output is correct const paymentOutput = tx.body.outputs[0] - expect(paymentOutput.assets.lovelace).toBe(100_000n) + expect(paymentOutput.assets.lovelace).toBe(969_750n) // Verify all change outputs meet minUTxO (corrected Babbage/Conway formula) const changeOutputs = tx.body.outputs.slice(1) expect(changeOutputs[0].assets.lovelace).toBe(1_150_770n) expect(changeOutputs[1].assets.lovelace).toBe(1_150_770n) expect(changeOutputs[2].assets.lovelace).toBe(1_155_080n) - expect(changeOutputs[3].assets.lovelace).toBe(2_259_487n) + expect(changeOutputs[3].assets.lovelace).toBe(1_389_737n) // Verify token distribution: all 3 tokens should be preserved in change outputs let totalTokenTypes = 0 @@ -117,7 +116,7 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { describe("Immediate fallback to single output when bundles unaffordable", () => { it("should fall back to single change output without reselection when bundles barely unaffordable", async () => { - let initialAssets = CoreAssets.fromLovelace(2_500_000n) + let initialAssets = CoreAssets.fromLovelace(3_000_000n) initialAssets = CoreAssets.addByHex(initialAssets, POLICY_A, toHex("TOKEN1"), 100n) initialAssets = CoreAssets.addByHex(initialAssets, POLICY_B, toHex("TOKEN2"), 200n) initialAssets = CoreAssets.addByHex(initialAssets, POLICY_C, toHex("TOKEN3"), 300n) @@ -125,7 +124,7 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { transactionId: "c".repeat(64), index: 0, address: CHANGE_ADDRESS, - lovelace: 2_500_000n + lovelace: 3_000_000n }) const initialUtxo = new CoreUTxO.UTxO({ ...initialUtxoBase, assets: initialAssets }) @@ -139,7 +138,7 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ address: CoreAddress.fromBech32(DESTINATION_ADDRESS), - assets: CoreAssets.fromLovelace(100_000n) + assets: CoreAssets.fromLovelace(1_000_000n) // Above min-ADA so no bump occurs }) const signBuilder = await builder.build({ @@ -162,19 +161,19 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { // Verify payment output const paymentOutput = tx.body.outputs[0] - expect(paymentOutput.assets.lovelace).toBe(100_000n) + expect(paymentOutput.assets.lovelace).toBe(1_000_000n) // Verify change output has correct amount after fee convergence - // Input: 2,500,000, Payment: 100,000, Fee: exact - // Expected change: 2,500,000 - 100,000 - fee + // Input: 3,000,000, Payment: 1,000,000, Fee: 173,553 + // Available change 1,826,447 < subdivideThreshold * 3 (1,500,000) → immediate single-output fallback const changeOutput = tx.body.outputs[1] - expect(changeOutput.assets.lovelace).toBe(2_226_447n) + expect(changeOutput.assets.lovelace).toBe(1_826_447n) // Verify fee is exact for single-output transaction expect(tx.body.fee).toBe(173_553n) // Balance equation must hold - expect(changeOutput.assets.lovelace + paymentOutput.assets.lovelace + tx.body.fee).toBe(2_500_000n) + expect(changeOutput.assets.lovelace + paymentOutput.assets.lovelace + tx.body.fee).toBe(3_000_000n) // Verify all 3 tokens are in the single change output let totalTokenTypes = 0 @@ -209,7 +208,8 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { assets: CoreAssets.fromLovelace(200_000n) }) - // Expect build to throw error + // With min-ADA enforcement, the 200_000n payment is bumped to ~969_750n, + // which exceeds the 500_000n input → coin selection fails before reaching change validation. await expect(async () => { await builder.build({ changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), @@ -222,7 +222,7 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { } } }) - }).rejects.toThrow(/Native assets present/) + }).rejects.toThrow(/Coin selection failed/) }) }) @@ -312,14 +312,14 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { transactionId: "6".repeat(64), index: 0, address: CHANGE_ADDRESS, - lovelace: 350_000n + lovelace: 1_500_000n }) const builder = makeTxBuilder({ chain: mainnet }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ address: CoreAddress.fromBech32(DESTINATION_ADDRESS), - assets: CoreAssets.fromLovelace(100_000n) + assets: CoreAssets.fromLovelace(969_750n) // At min-ADA so no bump; leftover ~530k < minUTxO → drainTo fires }) const signBuilder = await builder.build({ @@ -349,14 +349,14 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { transactionId: "7".repeat(64), index: 0, address: CHANGE_ADDRESS, - lovelace: 350_000n + lovelace: 1_500_000n }) const builder = makeTxBuilder({ chain: mainnet }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ address: CoreAddress.fromBech32(DESTINATION_ADDRESS), - assets: CoreAssets.fromLovelace(100_000n) + assets: CoreAssets.fromLovelace(969_750n) // At min-ADA so no bump; leftover ~530k < minUTxO → burn fires }) const signBuilder = await builder.build({ @@ -376,7 +376,7 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { expect(tx.body.inputs).toHaveLength(1) expect(tx.body.outputs).toHaveLength(1) // Only payment - expect(tx.body.outputs[0].assets.lovelace).toBe(100_000n) // Payment unchanged (leftover burned as fee) + expect(tx.body.outputs[0].assets.lovelace).toBe(969_750n) // Payment at min-ADA (leftover burned as fee) }) }) })