diff --git a/.changeset/odd-cooks-jump.md b/.changeset/odd-cooks-jump.md new file mode 100644 index 00000000..3d73857f --- /dev/null +++ b/.changeset/odd-cooks-jump.md @@ -0,0 +1,52 @@ +--- +"@evolution-sdk/devnet": patch +"@evolution-sdk/evolution": patch +--- + +### TxBuilder Composition API + +Add `compose()` and `getPrograms()` methods for modular transaction building: + +```ts +// Create reusable builder fragments +const mintBuilder = client.newTx() + .mintAssets({ policyId, assets: { tokenName: 1n }, redeemer }) + .attachScript({ script: mintingPolicy }) + +const metadataBuilder = client.newTx() + .attachMetadata({ label: 674n, metadata: "Composed transaction" }) + +// Compose multiple builders into one transaction +const tx = await client.newTx() + .payToAddress({ address, assets: { lovelace: 5_000_000n } }) + .compose(mintBuilder) + .compose(metadataBuilder) + .build() +``` + +**Features:** +- Merge operations from multiple builders into a single transaction +- Snapshot accumulated operations with `getPrograms()` for inspection +- Compose builders from different client instances +- Works with all builder methods (payments, validity, metadata, minting, staking, etc.) + +### Fixed Validity Interval Fee Calculation Bug + +Fixed bug where validity interval fields (`ttl` and `validityIntervalStart`) were not included during fee calculation, causing "insufficient fee" errors when using `setValidity()`. + +**Root Cause**: Validity fields were being added during transaction assembly AFTER fee calculation completed, causing the actual transaction to be 3-8 bytes larger than estimated. + +**Fix**: Convert validity Unix times to slots BEFORE the fee calculation loop and include them in the TransactionBody during size estimation. + +### Error Type Corrections + +Corrected error types for pure constructor functions to use `never` instead of `TransactionBuilderError`: +- `makeTxOutput` - creates TransactionOutput +- `txOutputToTransactionOutput` - creates TransactionOutput +- `mergeAssetsIntoUTxO` - creates UTxO +- `mergeAssetsIntoOutput` - creates TransactionOutput +- `buildTransactionInputs` - creates and sorts TransactionInputs + +### Error Message Improvements + +Enhanced error messages throughout the builder to include underlying error details for better debugging. diff --git a/docs/content/docs/modules/core/Metadata.mdx b/docs/content/docs/modules/core/Metadata.mdx index 667d6815..98b13e59 100644 --- a/docs/content/docs/modules/core/Metadata.mdx +++ b/docs/content/docs/modules/core/Metadata.mdx @@ -497,8 +497,8 @@ Added in v2.0.0 ## MetadataLabel -Schema for transaction metadatum label (uint - unbounded positive integer). -Uses Numeric.NonNegativeInteger for consistency with other numeric types. +Schema for transaction metadatum label (uint64 per Cardano CDDL spec). +Labels must be in range 0 to 2^64-1. **Signature** diff --git a/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx b/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx index 461418bb..5241796a 100644 --- a/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx +++ b/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx @@ -633,7 +633,7 @@ export interface TransactionBuilderBase { /** * Attach metadata to the transaction. * - * Metadata is stored in the auxiliary data section and identified by labels (0-255) + * Metadata is stored in the auxiliary data section and identified by numeric labels * following the CIP-10 standard. Common use cases include: * - Transaction messages/comments (label 674, CIP-20) * - NFT metadata (label 721, CIP-25) @@ -648,28 +648,19 @@ export interface TransactionBuilderBase { * * @example * ```typescript - * import * as TransactionMetadatum from "@evolution-sdk/core/TransactionMetadatum" + * import { fromEntries } from "@evolution-sdk/evolution/core/TransactionMetadatum" * * // Attach a simple message (CIP-20) * const tx = await builder * .payToAddress({ address, assets: { lovelace: 2_000_000n } }) - * .attachMetadata({ - * label: 674n, - * metadata: TransactionMetadatum.makeTransactionMetadatumMap( - * new Map([[0n, TransactionMetadatum.makeTransactionMetadatumText("Hello, Cardano!")]]) - * ) - * }) + * .attachMetadata({ label: 674n, metadata: "Hello, Cardano!" }) * .build() * * // Attach NFT metadata (CIP-25) - * const nftMetadata = TransactionMetadatum.makeTransactionMetadatumMap( - * new Map([ - * [TransactionMetadatum.makeTransactionMetadatumText("name"), - * TransactionMetadatum.makeTransactionMetadatumText("My NFT #42")], - * [TransactionMetadatum.makeTransactionMetadatumText("image"), - * TransactionMetadatum.makeTransactionMetadatumText("ipfs://Qm...")], - * ]) - * ) + * const nftMetadata = fromEntries([ + * ["name", "My NFT #42"], + * ["image", "ipfs://Qm..."] + * ]) * const tx = await builder * .mintAssets({ assets: { [policyId + assetName]: 1n } }) * .attachMetadata({ label: 721n, metadata: nftMetadata }) @@ -681,6 +672,65 @@ export interface TransactionBuilderBase { */ readonly attachMetadata: (params: AttachMetadataParams) => this + // ============================================================================ + // Composition Methods + // ============================================================================ + + /** + * Compose this builder with another builder's accumulated operations. + * + * Merges all queued operations from another transaction builder into this one. + * The other builder's programs are captured at compose time and will be executed + * when build() is called on this builder. + * + * This enables modular transaction building where common patterns can be + * encapsulated in reusable builder fragments. + * + * **Important**: Composition is one-way - changes to the other builder after + * compose() is called will not affect this builder. + * + * @example + * ```typescript + * // Create reusable builder for common operations + * const mintBuilder = builder + * .mintAssets({ policyId, assets: { tokenName: 1n }, redeemer }) + * .attachScript({ script: mintingPolicy }) + * + * // Compose into a transaction that also pays to an address + * const tx = await builder + * .payToAddress({ address, assets: { lovelace: 5_000_000n } }) + * .compose(mintBuilder) + * .build() + * + * // Compose multiple builders + * const fullTx = await builder + * .compose(mintBuilder) + * .compose(metadataBuilder) + * .compose(certBuilder) + * .build() + * ``` + * + * @param other - Another transaction builder whose operations will be merged + * @returns The same builder for method chaining + * + * @since 2.0.0 + * @category composition-methods + */ + readonly compose: (other: TransactionBuilder) => this + + /** + * Get a snapshot of the accumulated programs. + * + * Returns a read-only copy of all queued operations that have been added + * to this builder. Useful for inspection, debugging, or advanced composition patterns. + * + * @returns Read-only array of accumulated program steps + * + * @since 2.0.0 + * @category composition-methods + */ + readonly getPrograms: () => ReadonlyArray + // ============================================================================ // Transaction Chaining Methods // ============================================================================ diff --git a/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx b/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx index 5de6e022..b66b43db 100644 --- a/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx +++ b/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx @@ -78,7 +78,7 @@ Uses Core UTxO types directly. ```ts export declare const buildTransactionInputs: ( utxos: ReadonlyArray -) => Effect.Effect, TransactionBuilderError> +) => Effect.Effect, never> ``` Added in v2.0.0 @@ -187,7 +187,7 @@ export declare const calculateFeeIteratively: ( } >, protocolParams: { minFeeCoefficient: bigint; minFeeConstant: bigint; priceMem?: number; priceStep?: number } -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -350,7 +350,7 @@ export declare const makeTxOutput: (params: { assets: CoreAssets.Assets datum?: DatumOption.DatumOption scriptRef?: CoreScript.Script -}) => Effect.Effect +}) => Effect.Effect ``` Added in v2.0.0 @@ -368,7 +368,7 @@ Use case: Draining wallet by merging leftover into an existing payment output. export declare const mergeAssetsIntoOutput: ( output: TxOut.TransactionOutput, additionalAssets: CoreAssets.Assets -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -386,7 +386,7 @@ Use case: Draining wallet by merging leftover into an existing payment output. export declare const mergeAssetsIntoUTxO: ( utxo: CoreUTxO.UTxO, additionalAssets: CoreAssets.Assets -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/Attach.mdx b/docs/content/docs/modules/sdk/builders/operations/Attach.mdx index 5888e7f0..05d15af5 100644 --- a/docs/content/docs/modules/sdk/builders/operations/Attach.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/Attach.mdx @@ -28,9 +28,7 @@ Scripts must be attached before being referenced by transaction inputs or mintin **Signature** ```ts -export declare const attachScriptToState: ( - script: ScriptCore.Script -) => Effect.Effect +export declare const attachScriptToState: (script: ScriptCore.Script) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/AttachMetadata.mdx b/docs/content/docs/modules/sdk/builders/operations/AttachMetadata.mdx index 705927a5..99560d6a 100644 --- a/docs/content/docs/modules/sdk/builders/operations/AttachMetadata.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/AttachMetadata.mdx @@ -42,7 +42,7 @@ Implementation: ```ts export declare const createAttachMetadataProgram: ( params: AttachMetadataParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/Collect.mdx b/docs/content/docs/modules/sdk/builders/operations/Collect.mdx index ab071756..7de13dd1 100644 --- a/docs/content/docs/modules/sdk/builders/operations/Collect.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/Collect.mdx @@ -46,7 +46,7 @@ Implementation: ```ts export declare const createCollectFromProgram: ( params: CollectFromParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/Mint.mdx b/docs/content/docs/modules/sdk/builders/operations/Mint.mdx index 102c0e3e..8f07e6e5 100644 --- a/docs/content/docs/modules/sdk/builders/operations/Mint.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/Mint.mdx @@ -15,13 +15,13 @@ Added in v2.0.0

Table of contents

- [programs](#programs) - - [createMintProgram](#createmintprogram) + - [createMintAssetsProgram](#createmintassetsprogram) --- # programs -## createMintProgram +## createMintAssetsProgram Creates a ProgramStep for mintAssets operation. Adds minting information to the transaction and tracks redeemers by PolicyId. @@ -42,9 +42,9 @@ Implementation: **Signature** ```ts -export declare const createMintProgram: ( +export declare const createMintAssetsProgram: ( params: MintTokensParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/Pay.mdx b/docs/content/docs/modules/sdk/builders/operations/Pay.mdx index 7cae2885..e7f9857c 100644 --- a/docs/content/docs/modules/sdk/builders/operations/Pay.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/Pay.mdx @@ -35,9 +35,7 @@ Implementation: **Signature** ```ts -export declare const createPayToAddressProgram: ( - params: PayToAddressParams -) => Effect.Effect +export declare const createPayToAddressProgram: (params: PayToAddressParams) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/ReadFrom.mdx b/docs/content/docs/modules/sdk/builders/operations/ReadFrom.mdx index b2247758..8c5b0782 100644 --- a/docs/content/docs/modules/sdk/builders/operations/ReadFrom.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/ReadFrom.mdx @@ -45,7 +45,7 @@ Implementation: ```ts export declare const createReadFromProgram: ( params: ReadFromParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/Stake.mdx b/docs/content/docs/modules/sdk/builders/operations/Stake.mdx index f00f4c0c..f127f63a 100644 --- a/docs/content/docs/modules/sdk/builders/operations/Stake.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/Stake.mdx @@ -43,7 +43,7 @@ For script-controlled credentials, tracks redeemer for evaluation. ```ts export declare const createDelegateToProgram: ( params: DelegateToParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -61,7 +61,7 @@ For script-controlled credentials, tracks redeemer for evaluation. ```ts export declare const createDeregisterStakeProgram: ( params: DeregisterStakeParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -84,7 +84,7 @@ For script-controlled credentials, tracks redeemer for evaluation. ```ts export declare const createRegisterAndDelegateToProgram: ( params: RegisterAndDelegateToParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -100,7 +100,7 @@ Requires keyDeposit from protocol parameters. ```ts export declare const createRegisterStakeProgram: ( params: RegisterStakeParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -119,7 +119,7 @@ Use amount: 0n to trigger stake validator without withdrawing (coordinator patte export declare const createWithdrawProgram: ( params: WithdrawParams, config: TxBuilderConfig -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/operations/Validity.mdx b/docs/content/docs/modules/sdk/builders/operations/Validity.mdx index 8c97a5b4..adf9c8c3 100644 --- a/docs/content/docs/modules/sdk/builders/operations/Validity.mdx +++ b/docs/content/docs/modules/sdk/builders/operations/Validity.mdx @@ -44,7 +44,7 @@ Implementation: ```ts export declare const createSetValidityProgram: ( params: ValidityParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/phases/FeeCalculation.mdx b/docs/content/docs/modules/sdk/builders/phases/FeeCalculation.mdx index 3cf41edc..21f2b5a7 100644 --- a/docs/content/docs/modules/sdk/builders/phases/FeeCalculation.mdx +++ b/docs/content/docs/modules/sdk/builders/phases/FeeCalculation.mdx @@ -68,6 +68,6 @@ goto balance export declare const executeFeeCalculation: () => Effect.Effect< PhaseResult, TransactionBuilderError, - PhaseContextTag | TxContext | ProtocolParametersTag + PhaseContextTag | TxContext | ProtocolParametersTag | BuildOptionsTag > ``` diff --git a/packages/evolution-devnet/test/TxBuilder.Compose.test.ts b/packages/evolution-devnet/test/TxBuilder.Compose.test.ts new file mode 100644 index 00000000..93397d24 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Compose.test.ts @@ -0,0 +1,343 @@ +/** + * Devnet tests for TxBuilder compose operation. + * + * Tests the compose operation which merges multiple transaction builders + * into a single transaction, enabling modular and reusable transaction patterns. + */ + +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest" +import * as Cluster from "@evolution-sdk/devnet/Cluster" +import * as Config from "@evolution-sdk/devnet/Config" +import * as Genesis from "@evolution-sdk/devnet/Genesis" +import { Core } from "@evolution-sdk/evolution" +import * as Address from "@evolution-sdk/evolution/core/Address" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" + +// Alias for readability +const Time = Core.Time + +describe("TxBuilder compose (Devnet Submit)", () => { + let devnetCluster: Cluster.Cluster | undefined + let genesisConfig: Config.ShelleyGenesis + let genesisUtxos: ReadonlyArray = [] + + const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + + const createTestClient = (accountIndex: number = 0) => { + if (!devnetCluster) throw new Error("Cluster not initialized") + const slotConfig = Cluster.getSlotConfig(devnetCluster) + return createClient({ + network: 0, + slotConfig, + provider: { + type: "kupmios", + kupoUrl: "http://localhost:1451", + ogmiosUrl: "http://localhost:1346" + }, + wallet: { + type: "seed", + mnemonic: TEST_MNEMONIC, + accountIndex, + addressType: "Base" + } + }) + } + + beforeAll(async () => { + const tempClient = createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" } + }) + + const testAddress = await tempClient.address() + const testAddressHex = Address.toHex(testAddress) + + genesisConfig = { + ...Config.DEFAULT_SHELLEY_GENESIS, + slotLength: 0.02, + epochLength: 50, + activeSlotsCoeff: 1.0, + initialFunds: { [testAddressHex]: 500_000_000_000 } + } + + genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig) + + devnetCluster = await Cluster.make({ + clusterName: "compose-test", + ports: { node: 6009, submit: 9010 }, + shelleyGenesis: genesisConfig, + kupo: { enabled: true, port: 1451, logLevel: "Info" }, + ogmios: { enabled: true, port: 1346, logLevel: "info" } + }) + + await Cluster.start(devnetCluster) + await new Promise((resolve) => setTimeout(resolve, 3_000)) + }, 180_000) + + afterAll(async () => { + if (devnetCluster) { + await Cluster.stop(devnetCluster) + await Cluster.remove(devnetCluster) + } + }, 60_000) + + it("should compose payment with validity constraints", { timeout: 60_000 }, async () => { + const client = createTestClient(0) + const myAddress = await client.address() + + // Create a payment builder + const paymentBuilder = client.newTx().payToAddress({ + address: myAddress, + assets: Core.Assets.fromLovelace(5_000_000n) + }) + + // Create a validity builder + const validityBuilder = client.newTx().setValidity({ + to: Time.now() + 300_000n + }) + + // Compose payment and validity together + const signBuilder = await client + .newTx() + .compose(paymentBuilder) + .compose(validityBuilder) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + + // Verify validity interval is set + expect(tx.body.ttl).toBeDefined() + expect(tx.body.ttl).toBeGreaterThan(0n) + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should compose multiple payment builders to different addresses", { timeout: 60_000 }, async () => { + const client1 = createTestClient(0) + const client2 = createTestClient(1) + + const address1 = await client1.address() + const address2 = await client2.address() + + // Create separate payment builders for different addresses + const payment1 = client1.newTx().payToAddress({ + address: address1, + assets: Core.Assets.fromLovelace(3_000_000n) + }) + + const payment2 = client1.newTx().payToAddress({ + address: address2, + assets: Core.Assets.fromLovelace(2_000_000n) + }) + + const payment3 = client1.newTx().payToAddress({ + address: address1, + assets: Core.Assets.fromLovelace(4_000_000n) + }) + + // Compose all payments into single transaction + const signBuilder = await client1 + .newTx() + .compose(payment1) + .compose(payment2) + .compose(payment3) + .build() + + const tx = await signBuilder.toTransaction() + + // Should have at least 3 payment outputs (plus change) + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(3) + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client1.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should compose builder with addSigner + metadata + payment", { timeout: 60_000 }, async () => { + const client = createTestClient(0) + const myAddress = await client.address() + + // Extract payment credential + const paymentCredential = myAddress.paymentCredential + if (paymentCredential._tag !== "KeyHash") { + throw new Error("Expected KeyHash credential") + } + + // Create modular builders + const signerBuilder = client.newTx().addSigner({ keyHash: paymentCredential }) + + const metadataBuilder = client.newTx().attachMetadata({ + label: 674n, + metadata: "Multi-sig transaction" + }) + + const paymentBuilder = client.newTx().payToAddress({ + address: myAddress, + assets: Core.Assets.fromLovelace(6_000_000n) + }) + + // Compose all together + const signBuilder = await client + .newTx() + .compose(signerBuilder) + .compose(metadataBuilder) + .compose(paymentBuilder) + .build() + + const tx = await signBuilder.toTransaction() + + // Verify all components + expect(tx.body.requiredSigners?.length).toBe(1) + expect(tx.auxiliaryData).toBeDefined() + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(1) + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should compose stake registration with payment and metadata", { timeout: 90_000 }, async () => { + const client = createTestClient(0) + const myAddress = await client.address() + + // Get stake credential from address + if (!("stakingCredential" in myAddress) || !myAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + + const stakeCredential = myAddress.stakingCredential + + // Create separate builders for each operation + const stakeBuilder = client.newTx().registerStake({ stakeCredential }) + + const paymentBuilder = client.newTx().payToAddress({ + address: myAddress, + assets: Core.Assets.fromLovelace(10_000_000n) + }) + + const metadataBuilder = client.newTx().attachMetadata({ + label: 674n, + metadata: "Stake registration transaction" + }) + + // Compose all operations together + const signBuilder = await client + .newTx() + .compose(stakeBuilder) + .compose(paymentBuilder) + .compose(metadataBuilder) + .build() + + const tx = await signBuilder.toTransaction() + + // Verify all components + expect(tx.body.certificates).toBeDefined() + expect(tx.body.certificates?.length).toBe(1) + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(1) + expect(tx.auxiliaryData).toBeDefined() + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should verify getPrograms returns accumulated operations", { timeout: 30_000 }, async () => { + const client = createTestClient(0) + const myAddress = await client.address() + + // Build a transaction with multiple operations + const builder = client + .newTx() + .payToAddress({ + address: myAddress, + assets: Core.Assets.fromLovelace(1_000_000n) + }) + .attachMetadata({ + label: 1n, + metadata: "Test" + }) + + // Get programs snapshot + const programs = builder.getPrograms() + + // Should have 2 programs (payToAddress + attachMetadata) + expect(programs.length).toBe(2) + expect(Array.isArray(programs)).toBe(true) + + // Add another operation + builder.payToAddress({ + address: myAddress, + assets: Core.Assets.fromLovelace(2_000_000n) + }) + + // Get programs again - should have 3 now + const programs2 = builder.getPrograms() + expect(programs2.length).toBe(3) + + // Original snapshot should still be 2 (immutable) + expect(programs.length).toBe(2) + }) + + it("should compose builders created from different clients", { timeout: 60_000 }, async () => { + const client1 = createTestClient(0) + const client2 = createTestClient(1) + + const address1 = await client1.address() + const address2 = await client2.address() + + // Create builders from different clients + const builder1 = client1.newTx().payToAddress({ + address: address1, + assets: Core.Assets.fromLovelace(5_000_000n) + }) + + const builder2 = client2.newTx().attachMetadata({ + label: 42n, + metadata: "Cross-client composition" + }) + + // Compose them together using client1 + const signBuilder = await client1 + .newTx() + .compose(builder1) + .compose(builder2) + .payToAddress({ + address: address2, + assets: Core.Assets.fromLovelace(3_000_000n) + }) + .build() + + const tx = await signBuilder.toTransaction() + + // Verify combined operations + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(2) + expect(tx.auxiliaryData).toBeDefined() + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client1.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Validity.test.ts b/packages/evolution-devnet/test/TxBuilder.Validity.test.ts index 7da30907..4fe1b0b3 100644 --- a/packages/evolution-devnet/test/TxBuilder.Validity.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.Validity.test.ts @@ -90,7 +90,7 @@ describe("TxBuilder Validity Interval", () => { } }, 60_000) - it("should build transaction with TTL and convert to slot correctly", { timeout: 60_000 }, async () => { + it("should build and submit transaction with TTL", { timeout: 60_000 }, async () => { const client = createTestClient(0) const myAddress = await client.address() @@ -113,9 +113,17 @@ describe("TxBuilder Validity Interval", () => { expect(typeof tx.body.ttl).toBe("bigint") expect(tx.body.ttl! > 0n).toBe(true) expect(tx.body.validityIntervalStart).toBeUndefined() + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) }) - it("should build transaction with both validity bounds and convert to slots", { timeout: 60_000 }, async () => { + it("should build and submit transaction with both validity bounds", { timeout: 60_000 }, async () => { const client = createTestClient(0) const myAddress = await client.address() @@ -130,7 +138,7 @@ describe("TxBuilder Validity Interval", () => { address: myAddress, assets: Core.Assets.fromLovelace(5_000_000n) }) - .build({ availableUtxos: [...genesisUtxos] }) + .build() const tx = await signBuilder.toTransaction() @@ -145,6 +153,14 @@ describe("TxBuilder Validity Interval", () => { // TTL should be after validity start expect(tx.body.ttl! > tx.body.validityIntervalStart!).toBe(true) + + // Submit and confirm + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(txHash.length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) }) it("should reject expired transaction", { timeout: 60_000 }, async () => { diff --git a/packages/evolution/docs/modules/core/Metadata.ts.md b/packages/evolution/docs/modules/core/Metadata.ts.md index 86dada09..74b50743 100644 --- a/packages/evolution/docs/modules/core/Metadata.ts.md +++ b/packages/evolution/docs/modules/core/Metadata.ts.md @@ -497,8 +497,8 @@ Added in v2.0.0 ## MetadataLabel -Schema for transaction metadatum label (uint - unbounded positive integer). -Uses Numeric.NonNegativeInteger for consistency with other numeric types. +Schema for transaction metadatum label (uint64 per Cardano CDDL spec). +Labels must be in range 0 to 2^64-1. **Signature** diff --git a/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md b/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md index 100c3ef5..d6729715 100644 --- a/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md @@ -633,7 +633,7 @@ export interface TransactionBuilderBase { /** * Attach metadata to the transaction. * - * Metadata is stored in the auxiliary data section and identified by labels (0-255) + * Metadata is stored in the auxiliary data section and identified by numeric labels * following the CIP-10 standard. Common use cases include: * - Transaction messages/comments (label 674, CIP-20) * - NFT metadata (label 721, CIP-25) @@ -648,28 +648,19 @@ export interface TransactionBuilderBase { * * @example * ```typescript - * import * as TransactionMetadatum from "@evolution-sdk/core/TransactionMetadatum" + * import { fromEntries } from "@evolution-sdk/evolution/core/TransactionMetadatum" * * // Attach a simple message (CIP-20) * const tx = await builder * .payToAddress({ address, assets: { lovelace: 2_000_000n } }) - * .attachMetadata({ - * label: 674n, - * metadata: TransactionMetadatum.makeTransactionMetadatumMap( - * new Map([[0n, TransactionMetadatum.makeTransactionMetadatumText("Hello, Cardano!")]]) - * ) - * }) + * .attachMetadata({ label: 674n, metadata: "Hello, Cardano!" }) * .build() * * // Attach NFT metadata (CIP-25) - * const nftMetadata = TransactionMetadatum.makeTransactionMetadatumMap( - * new Map([ - * [TransactionMetadatum.makeTransactionMetadatumText("name"), - * TransactionMetadatum.makeTransactionMetadatumText("My NFT #42")], - * [TransactionMetadatum.makeTransactionMetadatumText("image"), - * TransactionMetadatum.makeTransactionMetadatumText("ipfs://Qm...")], - * ]) - * ) + * const nftMetadata = fromEntries([ + * ["name", "My NFT #42"], + * ["image", "ipfs://Qm..."] + * ]) * const tx = await builder * .mintAssets({ assets: { [policyId + assetName]: 1n } }) * .attachMetadata({ label: 721n, metadata: nftMetadata }) @@ -681,6 +672,65 @@ export interface TransactionBuilderBase { */ readonly attachMetadata: (params: AttachMetadataParams) => this + // ============================================================================ + // Composition Methods + // ============================================================================ + + /** + * Compose this builder with another builder's accumulated operations. + * + * Merges all queued operations from another transaction builder into this one. + * The other builder's programs are captured at compose time and will be executed + * when build() is called on this builder. + * + * This enables modular transaction building where common patterns can be + * encapsulated in reusable builder fragments. + * + * **Important**: Composition is one-way - changes to the other builder after + * compose() is called will not affect this builder. + * + * @example + * ```typescript + * // Create reusable builder for common operations + * const mintBuilder = builder + * .mintAssets({ policyId, assets: { tokenName: 1n }, redeemer }) + * .attachScript({ script: mintingPolicy }) + * + * // Compose into a transaction that also pays to an address + * const tx = await builder + * .payToAddress({ address, assets: { lovelace: 5_000_000n } }) + * .compose(mintBuilder) + * .build() + * + * // Compose multiple builders + * const fullTx = await builder + * .compose(mintBuilder) + * .compose(metadataBuilder) + * .compose(certBuilder) + * .build() + * ``` + * + * @param other - Another transaction builder whose operations will be merged + * @returns The same builder for method chaining + * + * @since 2.0.0 + * @category composition-methods + */ + readonly compose: (other: TransactionBuilder) => this + + /** + * Get a snapshot of the accumulated programs. + * + * Returns a read-only copy of all queued operations that have been added + * to this builder. Useful for inspection, debugging, or advanced composition patterns. + * + * @returns Read-only array of accumulated program steps + * + * @since 2.0.0 + * @category composition-methods + */ + readonly getPrograms: () => ReadonlyArray + // ============================================================================ // Transaction Chaining Methods // ============================================================================ diff --git a/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md b/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md index 0ce512cc..994ea57d 100644 --- a/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md @@ -78,7 +78,7 @@ Uses Core UTxO types directly. ```ts export declare const buildTransactionInputs: ( utxos: ReadonlyArray -) => Effect.Effect, TransactionBuilderError> +) => Effect.Effect, never> ``` Added in v2.0.0 @@ -187,7 +187,7 @@ export declare const calculateFeeIteratively: ( } >, protocolParams: { minFeeCoefficient: bigint; minFeeConstant: bigint; priceMem?: number; priceStep?: number } -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -350,7 +350,7 @@ export declare const makeTxOutput: (params: { assets: CoreAssets.Assets datum?: DatumOption.DatumOption scriptRef?: CoreScript.Script -}) => Effect.Effect +}) => Effect.Effect ``` Added in v2.0.0 @@ -368,7 +368,7 @@ Use case: Draining wallet by merging leftover into an existing payment output. export declare const mergeAssetsIntoOutput: ( output: TxOut.TransactionOutput, additionalAssets: CoreAssets.Assets -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -386,7 +386,7 @@ Use case: Draining wallet by merging leftover into an existing payment output. export declare const mergeAssetsIntoUTxO: ( utxo: CoreUTxO.UTxO, additionalAssets: CoreAssets.Assets -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/Attach.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/Attach.ts.md index f8280ca1..ab7fbd1c 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/Attach.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/Attach.ts.md @@ -28,9 +28,7 @@ Scripts must be attached before being referenced by transaction inputs or mintin **Signature** ```ts -export declare const attachScriptToState: ( - script: ScriptCore.Script -) => Effect.Effect +export declare const attachScriptToState: (script: ScriptCore.Script) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/AttachMetadata.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/AttachMetadata.ts.md index bd1fb530..fc75f655 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/AttachMetadata.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/AttachMetadata.ts.md @@ -42,7 +42,7 @@ Implementation: ```ts export declare const createAttachMetadataProgram: ( params: AttachMetadataParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/Collect.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/Collect.ts.md index 78d2b228..3c2ddcd8 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/Collect.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/Collect.ts.md @@ -46,7 +46,7 @@ Implementation: ```ts export declare const createCollectFromProgram: ( params: CollectFromParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/Mint.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/Mint.ts.md index c6c0750c..53eaac89 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/Mint.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/Mint.ts.md @@ -15,13 +15,13 @@ Added in v2.0.0

Table of contents

- [programs](#programs) - - [createMintProgram](#createmintprogram) + - [createMintAssetsProgram](#createmintassetsprogram) --- # programs -## createMintProgram +## createMintAssetsProgram Creates a ProgramStep for mintAssets operation. Adds minting information to the transaction and tracks redeemers by PolicyId. @@ -42,9 +42,9 @@ Implementation: **Signature** ```ts -export declare const createMintProgram: ( +export declare const createMintAssetsProgram: ( params: MintTokensParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/Pay.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/Pay.ts.md index 4715a81b..e61b1cd6 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/Pay.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/Pay.ts.md @@ -35,9 +35,7 @@ Implementation: **Signature** ```ts -export declare const createPayToAddressProgram: ( - params: PayToAddressParams -) => Effect.Effect +export declare const createPayToAddressProgram: (params: PayToAddressParams) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/ReadFrom.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/ReadFrom.ts.md index a5abe5c2..956d71ab 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/ReadFrom.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/ReadFrom.ts.md @@ -45,7 +45,7 @@ Implementation: ```ts export declare const createReadFromProgram: ( params: ReadFromParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/Stake.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/Stake.ts.md index 90cf7e62..b93dcd14 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/Stake.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/Stake.ts.md @@ -43,7 +43,7 @@ For script-controlled credentials, tracks redeemer for evaluation. ```ts export declare const createDelegateToProgram: ( params: DelegateToParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -61,7 +61,7 @@ For script-controlled credentials, tracks redeemer for evaluation. ```ts export declare const createDeregisterStakeProgram: ( params: DeregisterStakeParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -84,7 +84,7 @@ For script-controlled credentials, tracks redeemer for evaluation. ```ts export declare const createRegisterAndDelegateToProgram: ( params: RegisterAndDelegateToParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -100,7 +100,7 @@ Requires keyDeposit from protocol parameters. ```ts export declare const createRegisterStakeProgram: ( params: RegisterStakeParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 @@ -119,7 +119,7 @@ Use amount: 0n to trigger stake validator without withdrawing (coordinator patte export declare const createWithdrawProgram: ( params: WithdrawParams, config: TxBuilderConfig -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/operations/Validity.ts.md b/packages/evolution/docs/modules/sdk/builders/operations/Validity.ts.md index afe3af53..52717333 100644 --- a/packages/evolution/docs/modules/sdk/builders/operations/Validity.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/operations/Validity.ts.md @@ -44,7 +44,7 @@ Implementation: ```ts export declare const createSetValidityProgram: ( params: ValidityParams -) => Effect.Effect +) => Effect.Effect ``` Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/phases/FeeCalculation.ts.md b/packages/evolution/docs/modules/sdk/builders/phases/FeeCalculation.ts.md index 82cfd044..795fc0f5 100644 --- a/packages/evolution/docs/modules/sdk/builders/phases/FeeCalculation.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/phases/FeeCalculation.ts.md @@ -68,6 +68,6 @@ goto balance export declare const executeFeeCalculation: () => Effect.Effect< PhaseResult, TransactionBuilderError, - PhaseContextTag | TxContext | ProtocolParametersTag + PhaseContextTag | TxContext | ProtocolParametersTag | BuildOptionsTag > ``` diff --git a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts index f38b104f..730e3ef3 100644 --- a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts @@ -108,7 +108,7 @@ export const makeSignBuilder = (params: { const walletWitnessSet = yield* wallet.Effect.signTx(transaction, { utxos, referenceUtxos }).pipe( Effect.mapError( (walletError) => - new TransactionBuilderError({ message: "Failed to sign transaction", cause: walletError }) + new TransactionBuilderError({ message: `Failed to sign transaction: ${walletError.message}`, cause: walletError }) ) ) @@ -257,7 +257,7 @@ export const makeSignBuilder = (params: { const witnessSet = yield* wallet.Effect.signTx(transaction, { utxos, referenceUtxos }).pipe( Effect.mapError( (walletError) => - new TransactionBuilderError({ message: "Failed to create partial signature", cause: walletError }) + new TransactionBuilderError({ message: `Failed to create partial signature: ${walletError.message}`, cause: walletError }) ) ) diff --git a/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts b/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts index 2380fc6a..d1b07bb1 100644 --- a/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts @@ -42,7 +42,10 @@ export const makeSubmitBuilder = ( const txHash = yield* provider.Effect.submitTx(txCborHex).pipe( Effect.mapError( (providerError) => - new TransactionBuilderError({ message: "Failed to submit transaction", cause: providerError }) + new TransactionBuilderError({ + message: `Failed to submit transaction: ${providerError.message}`, + cause: providerError + }) ) ) diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 6456fd1b..9119f70f 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -53,11 +53,37 @@ import { createAddSignerProgram } from "./operations/AddSigner.js" import { attachScriptToState } from "./operations/Attach.js" import { createAttachMetadataProgram } from "./operations/AttachMetadata.js" import { createCollectFromProgram } from "./operations/Collect.js" -import { createMintProgram } from "./operations/Mint.js" -import type { AddSignerParams, AttachMetadataParams, AuthCommitteeHotParams, CollectFromParams, DelegateToParams, DeregisterDRepParams, DeregisterStakeParams, MintTokensParams, PayToAddressParams, ReadFromParams, RegisterAndDelegateToParams, RegisterDRepParams, RegisterPoolParams, RegisterStakeParams, ResignCommitteeColdParams, RetirePoolParams, UpdateDRepParams, ValidityParams, WithdrawParams } from "./operations/Operations.js" +import { createMintAssetsProgram } from "./operations/Mint.js" +import type { + AddSignerParams, + AttachMetadataParams, + AuthCommitteeHotParams, + CollectFromParams, + DelegateToParams, + DeregisterDRepParams, + DeregisterStakeParams, + MintTokensParams, + PayToAddressParams, + ReadFromParams, + RegisterAndDelegateToParams, + RegisterDRepParams, + RegisterPoolParams, + RegisterStakeParams, + ResignCommitteeColdParams, + RetirePoolParams, + UpdateDRepParams, + ValidityParams, + WithdrawParams +} from "./operations/Operations.js" import { createPayToAddressProgram } from "./operations/Pay.js" import { createReadFromProgram } from "./operations/ReadFrom.js" -import { createDelegateToProgram, createDeregisterStakeProgram, createRegisterAndDelegateToProgram, createRegisterStakeProgram, createWithdrawProgram } from "./operations/Stake.js" +import { + createDelegateToProgram, + createDeregisterStakeProgram, + createRegisterAndDelegateToProgram, + createRegisterStakeProgram, + createWithdrawProgram +} from "./operations/Stake.js" import { createSetValidityProgram } from "./operations/Validity.js" import { executeBalance } from "./phases/Balance.js" import { executeChangeCreation } from "./phases/ChangeCreation.js" @@ -92,7 +118,15 @@ export class TransactionBuilderError extends Data.TaggedError("TransactionBuilde /** * Build phases */ -type Phase = "selection" | "changeCreation" | "feeCalculation" | "balance" | "evaluation" | "collateral" | "fallback" | "complete" +type Phase = + | "selection" + | "changeCreation" + | "feeCalculation" + | "balance" + | "evaluation" + | "collateral" + | "fallback" + | "complete" /** * BuildContext - state machine context @@ -202,10 +236,7 @@ const resolveAvailableUtxos = ( } if (config.wallet && config.provider) { - return Effect.flatMap( - config.wallet.Effect.address(), - (addr) => config.provider!.Effect.getUtxos(addr) - ) + return Effect.flatMap(config.wallet.Effect.address(), (addr) => config.provider!.Effect.getUtxos(addr)) } return Effect.fail( @@ -238,7 +269,7 @@ const resolveEvaluator = (config: TxBuilderConfig, options?: BuildOptions): Eval Effect.mapError( (providerError) => new EvaluationError({ - message: "Provider evaluation failed", + message: `Provider evaluation failed: ${providerError.message}`, cause: providerError }) ) @@ -363,7 +394,7 @@ const assembleAndValidateTransaction = Effect.gen(function* () { // SAFETY CHECK: Validate transaction size against protocol limit // Include collateral UTxOs in witness estimation - they require VKey witnesses too! - const allUtxosForWitnesses = finalState.collateral + const allUtxosForWitnesses = finalState.collateral ? [...selectedUtxos, ...finalState.collateral.inputs] : selectedUtxos const fakeWitnessSet = yield* buildFakeWitnessSet(allUtxosForWitnesses) @@ -504,7 +535,7 @@ const buildPartialEffectCore = ( Effect.mapError( (error) => new TransactionBuilderError({ - message: "Partial build failed", + message: `Partial build failed: ${error.message}`, cause: error }) ) @@ -642,7 +673,13 @@ export class EvaluationError extends Data.TaggedError("EvaluationError")<{ const lines = ["Script evaluation failed:"] for (const f of this.failures) { const labelPart = f.label ? ` [${f.label}]` : "" - const refPart = f.utxoRef ? ` UTxO: ${f.utxoRef}` : f.credential ? ` Credential: ${f.credential}` : f.policyId ? ` Policy: ${f.policyId}` : "" + const refPart = f.utxoRef + ? ` UTxO: ${f.utxoRef}` + : f.credential + ? ` Credential: ${f.credential}` + : f.policyId + ? ` Policy: ${f.policyId}` + : "" lines.push(` ${f.purpose}:${f.index}${labelPart}${refPart}`) lines.push(` Error: ${f.validationError}`) if (f.traces.length > 0) { @@ -1407,7 +1444,10 @@ export class TxBuilderConfigTag extends Context.Tag("TxBuilderConfig")>() {} +export class AvailableUtxosTag extends Context.Tag("AvailableUtxos")< + AvailableUtxosTag, + ReadonlyArray +>() {} /** * Context tag providing BuildOptions for the current build. @@ -1581,9 +1621,9 @@ export interface TransactionBuilderBase { * ```typescript * // Mint tokens from a native script policy * const tx = await builder - * .mintAssets({ - * assets: { - * "": 1000n + * .mintAssets({ + * assets: { + * "": 1000n * } * }) * .build() @@ -1591,9 +1631,9 @@ export interface TransactionBuilderBase { * // Mint from Plutus script policy with redeemer * const tx = await builder * .attachScript(mintingScript) - * .mintAssets({ - * assets: { - * "": 1000n + * .mintAssets({ + * assets: { + * "": 1000n * }, * redeemer: myRedeemer * }) @@ -1947,6 +1987,65 @@ export interface TransactionBuilderBase { */ readonly attachMetadata: (params: AttachMetadataParams) => this + // ============================================================================ + // Composition Methods + // ============================================================================ + + /** + * Compose this builder with another builder's accumulated operations. + * + * Merges all queued operations from another transaction builder into this one. + * The other builder's programs are captured at compose time and will be executed + * when build() is called on this builder. + * + * This enables modular transaction building where common patterns can be + * encapsulated in reusable builder fragments. + * + * **Important**: Composition is one-way - changes to the other builder after + * compose() is called will not affect this builder. + * + * @example + * ```typescript + * // Create reusable builder for common operations + * const mintBuilder = builder + * .mintAssets({ policyId, assets: { tokenName: 1n }, redeemer }) + * .attachScript({ script: mintingPolicy }) + * + * // Compose into a transaction that also pays to an address + * const tx = await builder + * .payToAddress({ address, assets: { lovelace: 5_000_000n } }) + * .compose(mintBuilder) + * .build() + * + * // Compose multiple builders + * const fullTx = await builder + * .compose(mintBuilder) + * .compose(metadataBuilder) + * .compose(certBuilder) + * .build() + * ``` + * + * @param other - Another transaction builder whose operations will be merged + * @returns The same builder for method chaining + * + * @since 2.0.0 + * @category composition-methods + */ + readonly compose: (other: TransactionBuilder) => this + + /** + * Get a snapshot of the accumulated programs. + * + * Returns a read-only copy of all queued operations that have been added + * to this builder. Useful for inspection, debugging, or advanced composition patterns. + * + * @returns Read-only array of accumulated program steps + * + * @since 2.0.0 + * @category composition-methods + */ + readonly getPrograms: () => ReadonlyArray + // ============================================================================ // Transaction Chaining Methods // ============================================================================ @@ -2175,7 +2274,7 @@ export function makeTxBuilder(config: TxBuilderConfig) { mintAssets: (params: MintTokensParams) => { // Create ProgramStep for deferred execution - const program = createMintProgram(params) + const program = createMintAssetsProgram(params) programs.push(program) return txBuilder // Return same instance for chaining }, @@ -2253,10 +2352,15 @@ export function makeTxBuilder(config: TxBuilderConfig) { programs.push(createAttachMetadataProgram(params)) return txBuilder }, + compose: (other: TransactionBuilder) => { + const otherPrograms = other.getPrograms() + if (otherPrograms.length > 0) { + programs.push(...otherPrograms) + } + return txBuilder + }, - // ============================================================================ - // Hybrid completion methods - Execute with fresh state - // ============================================================================ + getPrograms: () => [...programs], buildEffect: (options?: BuildOptions) => { return makeBuild(config, programs, options) diff --git a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts index 95315a0a..ffd8775f 100644 --- a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts @@ -237,7 +237,7 @@ export const makeDatumOption = (datum: Datum.Datum): Effect.Effect new TransactionBuilderError({ - message: `Failed to parse datum: ${JSON.stringify(datum)}`, + message: `Failed to parse datum: ${error.message}`, cause: error }) ) @@ -257,7 +257,7 @@ export const makeTxOutput = (params: { assets: CoreAssets.Assets datum?: DatumOption.DatumOption scriptRef?: CoreScript.Script -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { // Convert Script to ScriptRef for CBOR encoding if provided const scriptRefEncoded = params.scriptRef @@ -273,15 +273,7 @@ export const makeTxOutput = (params: { }) return output - }).pipe( - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: `Failed to create TxOutput`, - cause: error - }) - ) - ) + }) /** * Convert parameters to core TransactionOutput. @@ -297,7 +289,7 @@ export const txOutputToTransactionOutput = (params: { assets: CoreAssets.Assets datum?: DatumOption.DatumOption scriptRef?: CoreScript.Script -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { // Convert Script to ScriptRef for CBOR encoding if provided const scriptRefEncoded = params.scriptRef @@ -313,15 +305,7 @@ export const txOutputToTransactionOutput = (params: { }) return output - }).pipe( - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: `Failed to create transaction output`, - cause: error - }) - ) - ) + }) /** * Merge additional assets into an existing UTxO. @@ -335,7 +319,7 @@ export const txOutputToTransactionOutput = (params: { export const mergeAssetsIntoUTxO = ( utxo: CoreUTxO.UTxO, additionalAssets: CoreAssets.Assets -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // Merge assets using Core Assets helper const mergedAssets = CoreAssets.merge(utxo.assets, additionalAssets) @@ -348,15 +332,7 @@ export const mergeAssetsIntoUTxO = ( datumOption: utxo.datumOption, scriptRef: utxo.scriptRef }) - }).pipe( - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: "Failed to merge assets into UTxO", - cause: error - }) - ) - ) + }) /** * Merge additional assets into an existing TransactionOutput. @@ -370,7 +346,7 @@ export const mergeAssetsIntoUTxO = ( export const mergeAssetsIntoOutput = ( output: TxOut.TransactionOutput, additionalAssets: CoreAssets.Assets -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // Merge assets using Core Assets helper const mergedAssets = CoreAssets.merge(output.assets, additionalAssets) @@ -383,15 +359,7 @@ export const mergeAssetsIntoOutput = ( scriptRef: output.scriptRef }) return newOutput - }).pipe( - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: "Failed to merge assets into output", - cause: error - }) - ) - ) + }) // ============================================================================ // Transaction Assembly @@ -407,7 +375,7 @@ export const mergeAssetsIntoOutput = ( */ export const buildTransactionInputs = ( utxos: ReadonlyArray -): Effect.Effect, TransactionBuilderError> => +): Effect.Effect, never> => Effect.gen(function* () { // Convert each Core UTxO to TransactionInput const inputs: Array = [] @@ -440,15 +408,7 @@ export const buildTransactionInputs = ( }) return inputs - }).pipe( - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: "Failed to build transaction inputs", - cause: error - }) - ) - ) + }) /** * Assemble a Transaction from inputs, outputs, and calculated fee. @@ -692,7 +652,7 @@ export const assembleTransaction = ( Effect.mapError( (providerError) => new TransactionBuilderError({ - message: "Failed to fetch full protocol parameters for scriptDataHash calculation", + message: `Failed to fetch full protocol parameters for scriptDataHash calculation: ${providerError.message}`, cause: providerError }) ) @@ -822,7 +782,7 @@ export const assembleTransaction = ( Effect.mapError( (error) => new TransactionBuilderError({ - message: "Failed to assemble transaction", + message: `Failed to assemble transaction: ${error.message}`, cause: error }) ) @@ -858,7 +818,7 @@ export const calculateTransactionSize = ( Effect.mapError( (error) => new TransactionBuilderError({ - message: "Failed to calculate transaction size", + message: `Failed to calculate transaction size: ${error.message}`, cause: error }) ) @@ -1170,7 +1130,7 @@ export const calculateFeeIteratively = ( priceMem?: number priceStep?: number } -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // Get state to access mint field and collateral const stateRef = yield* TxContext @@ -1258,12 +1218,27 @@ export const calculateFeeIteratively = ( ] } + // Convert validity interval to slots for fee calculation + // Validity fields affect transaction size and must be included + const buildOptions = yield* BuildOptionsTag + const slotConfig = buildOptions.slotConfig! + let ttl: bigint | undefined + let validityIntervalStart: bigint | undefined + if (state.validity?.to !== undefined) { + ttl = Time.unixTimeToSlot(state.validity.to, slotConfig) + } + if (state.validity?.from !== undefined) { + validityIntervalStart = Time.unixTimeToSlot(state.validity.from, slotConfig) + } + while (iterations < maxIterations) { // Build transaction with current fee estimate const body = new TransactionBody.TransactionBody({ inputs: inputs as Array, outputs: transactionOutputs, fee: currentFee, + ttl, // Include TTL for accurate size calculation + validityIntervalStart, // Include validity start for accurate size calculation mint, // Include mint field for accurate size calculation scriptDataHash: placeholderScriptDataHash, // Include scriptDataHash for accurate size auxiliaryDataHash: placeholderAuxiliaryDataHash, // Include auxiliaryDataHash for accurate size @@ -1341,7 +1316,7 @@ export const calculateFeeIteratively = ( Effect.mapError( (error) => new TransactionBuilderError({ - message: "Fee calculation failed to converge", + message: `Fee calculation failed to converge: ${error.message}`, cause: error }) ) diff --git a/packages/evolution/src/sdk/builders/operations/Attach.ts b/packages/evolution/src/sdk/builders/operations/Attach.ts index 2a3dc198..7bde6cad 100644 --- a/packages/evolution/src/sdk/builders/operations/Attach.ts +++ b/packages/evolution/src/sdk/builders/operations/Attach.ts @@ -2,7 +2,7 @@ import { Effect, Ref } from "effect" import type * as ScriptCore from "../../../core/Script.js" import * as ScriptHashCore from "../../../core/ScriptHash.js" -import { TransactionBuilderError, TxContext } from "../TransactionBuilder.js" +import { TxContext } from "../TransactionBuilder.js" /** * Attaches a script to the transaction by storing it in the builder state. @@ -33,12 +33,4 @@ export const attachScriptToState = (script: ScriptCore.Script) => ...state, scripts: updatedScripts }) - }).pipe( - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: "Failed to attach script", - cause: error - }) - ) - ) + }) diff --git a/packages/evolution/src/sdk/builders/operations/AttachMetadata.ts b/packages/evolution/src/sdk/builders/operations/AttachMetadata.ts index 2daffd3b..e48d6ffa 100644 --- a/packages/evolution/src/sdk/builders/operations/AttachMetadata.ts +++ b/packages/evolution/src/sdk/builders/operations/AttachMetadata.ts @@ -29,7 +29,7 @@ import type { AttachMetadataParams } from "./Operations.js" * @since 2.0.0 * @category programs */ -export const createAttachMetadataProgram = (params: AttachMetadataParams) => +export const createAttachMetadataProgram = (params: AttachMetadataParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext diff --git a/packages/evolution/src/sdk/builders/operations/Collect.ts b/packages/evolution/src/sdk/builders/operations/Collect.ts index dda6d0a4..ac1b20a7 100644 --- a/packages/evolution/src/sdk/builders/operations/Collect.ts +++ b/packages/evolution/src/sdk/builders/operations/Collect.ts @@ -35,7 +35,7 @@ import type { CollectFromParams } from "./Operations.js" * @since 2.0.0 * @category programs */ -export const createCollectFromProgram = (params: CollectFromParams) => +export const createCollectFromProgram = (params: CollectFromParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext const state = yield* Ref.get(ctx) diff --git a/packages/evolution/src/sdk/builders/operations/Mint.ts b/packages/evolution/src/sdk/builders/operations/Mint.ts index e8843323..d2b005fe 100644 --- a/packages/evolution/src/sdk/builders/operations/Mint.ts +++ b/packages/evolution/src/sdk/builders/operations/Mint.ts @@ -34,7 +34,7 @@ import type { MintTokensParams } from "./Operations.js" * @since 2.0.0 * @category programs */ -export const createMintProgram = (params: MintTokensParams) => +export const createMintAssetsProgram = (params: MintTokensParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext diff --git a/packages/evolution/src/sdk/builders/operations/ReadFrom.ts b/packages/evolution/src/sdk/builders/operations/ReadFrom.ts index e776a193..1f4ab798 100644 --- a/packages/evolution/src/sdk/builders/operations/ReadFrom.ts +++ b/packages/evolution/src/sdk/builders/operations/ReadFrom.ts @@ -30,7 +30,7 @@ import type { ReadFromParams } from "./Operations.js" * @since 2.0.0 * @category programs */ -export const createReadFromProgram = (params: ReadFromParams) => +export const createReadFromProgram = (params: ReadFromParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext diff --git a/packages/evolution/src/sdk/builders/operations/Stake.ts b/packages/evolution/src/sdk/builders/operations/Stake.ts index 07b396c1..c81132f6 100644 --- a/packages/evolution/src/sdk/builders/operations/Stake.ts +++ b/packages/evolution/src/sdk/builders/operations/Stake.ts @@ -28,7 +28,7 @@ const credentialToKey = (credential: Certificate.StakeRegistration["stakeCredent * @since 2.0.0 * @category programs */ -export const createRegisterStakeProgram = (params: RegisterStakeParams) => +export const createRegisterStakeProgram = (params: RegisterStakeParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext const config = yield* TxBuilderConfigTag @@ -119,7 +119,7 @@ export const createRegisterStakeProgram = (params: RegisterStakeParams) => * @since 2.0.0 * @category programs */ -export const createDelegateToProgram = (params: DelegateToParams) => +export const createDelegateToProgram = (params: DelegateToParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext @@ -226,7 +226,7 @@ export const createDelegateToProgram = (params: DelegateToParams) => * @since 2.0.0 * @category programs */ -export const createRegisterAndDelegateToProgram = (params: RegisterAndDelegateToParams) => +export const createRegisterAndDelegateToProgram = (params: RegisterAndDelegateToParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext const config = yield* TxBuilderConfigTag @@ -349,7 +349,7 @@ export const createRegisterAndDelegateToProgram = (params: RegisterAndDelegateTo * @since 2.0.0 * @category programs */ -export const createDeregisterStakeProgram = (params: DeregisterStakeParams) => +export const createDeregisterStakeProgram = (params: DeregisterStakeParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext const config = yield* TxBuilderConfigTag @@ -437,7 +437,7 @@ export const createDeregisterStakeProgram = (params: DeregisterStakeParams) => * @since 2.0.0 * @category programs */ -export const createWithdrawProgram = (params: WithdrawParams, config: TxBuilderConfig) => +export const createWithdrawProgram = (params: WithdrawParams, config: TxBuilderConfig): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext diff --git a/packages/evolution/src/sdk/builders/operations/Validity.ts b/packages/evolution/src/sdk/builders/operations/Validity.ts index d1cc135e..5bb4d699 100644 --- a/packages/evolution/src/sdk/builders/operations/Validity.ts +++ b/packages/evolution/src/sdk/builders/operations/Validity.ts @@ -28,7 +28,7 @@ import type { ValidityParams } from "./Operations.js" * @since 2.0.0 * @category programs */ -export const createSetValidityProgram = (params: ValidityParams) => +export const createSetValidityProgram = (params: ValidityParams): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext diff --git a/packages/evolution/src/sdk/builders/phases/Evaluation.ts b/packages/evolution/src/sdk/builders/phases/Evaluation.ts index 4939427f..21bd35a1 100644 --- a/packages/evolution/src/sdk/builders/phases/Evaluation.ts +++ b/packages/evolution/src/sdk/builders/phases/Evaluation.ts @@ -382,7 +382,7 @@ export const executeEvaluation = (): Effect.Effect< Effect.mapError( (providerError) => new TransactionBuilderError({ - message: "Failed to fetch full protocol parameters for evaluation", + message: `Failed to fetch full protocol parameters for evaluation: ${providerError.message}`, cause: providerError }) ) @@ -594,7 +594,7 @@ export const executeEvaluation = (): Effect.Effect< }) return new TransactionBuilderError({ - message: "Script evaluation failed", + message: `Script evaluation failed: ${evalError.message}`, cause: enhancedError }) } diff --git a/packages/evolution/src/sdk/builders/phases/FeeCalculation.ts b/packages/evolution/src/sdk/builders/phases/FeeCalculation.ts index b2180a2c..73a92fb4 100644 --- a/packages/evolution/src/sdk/builders/phases/FeeCalculation.ts +++ b/packages/evolution/src/sdk/builders/phases/FeeCalculation.ts @@ -12,7 +12,7 @@ import { Effect, Ref } from "effect" import * as CoreAssets from "../../../core/Assets/index.js" import type { TransactionBuilderError } from "../TransactionBuilder.js" -import { PhaseContextTag, ProtocolParametersTag, TxContext } from "../TransactionBuilder.js" +import { BuildOptionsTag, PhaseContextTag, ProtocolParametersTag, TxContext } from "../TransactionBuilder.js" import { buildTransactionInputs, calculateFeeIteratively, calculateReferenceScriptFee } from "../TxBuilderImpl.js" import type { PhaseResult } from "./Phases.js" @@ -54,7 +54,7 @@ import type { PhaseResult } from "./Phases.js" export const executeFeeCalculation = (): Effect.Effect< PhaseResult, TransactionBuilderError, - PhaseContextTag | TxContext | ProtocolParametersTag + PhaseContextTag | TxContext | ProtocolParametersTag | BuildOptionsTag > => Effect.gen(function* () { // Step 1: Get contexts and current state