Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/evolution/src/sdk/builders/TransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ export class BuildOptionsTag extends Context.Tag("BuildOptions")<BuildOptionsTag
* @since 2.0.0
* @category model
*/
export type ProgramStep = Effect.Effect<void, TransactionBuilderError, TxContext | TxBuilderConfigTag>
export type ProgramStep = Effect.Effect<void, TransactionBuilderError, TxContext | TxBuilderConfigTag | ProtocolParametersTag>

// ============================================================================
// Voter Key
Expand Down
48 changes: 37 additions & 11 deletions packages/evolution/src/sdk/builders/operations/Pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,64 @@
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"

Check warning on line 12 in packages/evolution/src/sdk/builders/operations/Pay.ts

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

Imports "TransactionBuilderError" are only used as type
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
*/
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<void, TransactionBuilderError, ProtocolParametersTag | TxContext>
114 changes: 114 additions & 0 deletions packages/evolution/test/TxBuilder.PayMinUtxo.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, bigint>
) => {
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)
})
})
36 changes: 18 additions & 18 deletions packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -117,15 +116,15 @@ 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)
const initialUtxoBase = createCoreTestUtxo({
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 })

Expand All @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -222,7 +222,7 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => {
}
}
})
}).rejects.toThrow(/Native assets present/)
}).rejects.toThrow(/Coin selection failed/)
})
})

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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)
})
})
})
Loading