From b218b9e47c9d1080d7762db66d1a7b9d7ce06b39 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Mon, 27 Oct 2025 13:01:50 -0600 Subject: [PATCH] feat: integrate TransactionBuilder into Client with signing/submission flow - Add newTx() method to Client interfaces (ReadOnly, Signing) - Implement SignBuilder and SubmitBuilder for transaction lifecycle - Add generic type support (SignBuilder vs TransactionResultBase) - Refactor protocol parameters resolution to 2-way (BuildOptions > Provider) - Fix type inference with default generic in makeTxBuilder - Update test files to use new protocol parameters pattern --- docs/content/docs/modules/core/Function.mdx | 2 +- docs/content/docs/modules/core/Value.mdx | 17 + docs/content/docs/modules/sdk/Credential.mdx | 2 +- docs/content/docs/modules/sdk/Datum.mdx | 2 +- docs/content/docs/modules/sdk/Delegation.mdx | 2 +- .../docs/modules/sdk/Devnet/Devnet.mdx | 2 +- .../docs/modules/sdk/Devnet/DevnetDefault.mdx | 2 +- .../content/docs/modules/sdk/EvalRedeemer.mdx | 2 +- docs/content/docs/modules/sdk/Label.mdx | 2 +- docs/content/docs/modules/sdk/OutRef.mdx | 2 +- docs/content/docs/modules/sdk/PolicyId.mdx | 2 +- .../docs/modules/sdk/ProtocolParameters.mdx | 2 +- .../docs/modules/sdk/RewardAddress.mdx | 2 +- docs/content/docs/modules/sdk/Script.mdx | 2 +- docs/content/docs/modules/sdk/Type.mdx | 2 +- docs/content/docs/modules/sdk/UTxO.mdx | 2 +- docs/content/docs/modules/sdk/Unit.mdx | 2 +- .../docs/modules/sdk/builders/SignBuilder.mdx | 61 +- .../modules/sdk/builders/SignBuilderImpl.mdx | 51 + .../modules/sdk/builders/SubmitBuilder.mdx | 79 + .../sdk/builders/SubmitBuilderImpl.mdx | 45 + .../sdk/builders/TransactionBuilder.mdx | 231 +- .../sdk/builders/TransactionResult.mdx | 160 + .../modules/sdk/builders/TxBuilderImpl.mdx | 2 +- .../docs/modules/sdk/builders/Unfrack.mdx | 2 +- .../docs/modules/sdk/client/Client.mdx | 125 +- .../docs/modules/sdk/client/ClientImpl.mdx | 6 +- .../docs/modules/sdk/provider/Blockfrost.mdx | 2 +- .../docs/modules/sdk/provider/Koios.mdx | 2 +- .../docs/modules/sdk/provider/Kupmios.mdx | 2 +- .../docs/modules/sdk/provider/Maestro.mdx | 2 +- .../docs/modules/sdk/provider/Provider.mdx | 2 +- .../docs/modules/sdk/wallet/Derivation.mdx | 14 +- .../docs/modules/sdk/wallet/Wallet.mdx | 2 +- .../docs/modules/sdk/wallet/WalletNew.mdx | 6 +- .../docs/modules/utils/FeeValidation.mdx | 2 +- docs/content/docs/modules/utils/Hash.mdx | 2 +- .../docs/modules/utils/effect-runtime.mdx | 54 + docs/next-env.d.ts | 2 +- .../docs/modules/core/Function.ts.md | 2 +- .../evolution/docs/modules/core/Value.ts.md | 17 + .../docs/modules/sdk/Credential.ts.md | 2 +- .../evolution/docs/modules/sdk/Datum.ts.md | 2 +- .../docs/modules/sdk/Delegation.ts.md | 2 +- .../docs/modules/sdk/Devnet/Devnet.ts.md | 2 +- .../modules/sdk/Devnet/DevnetDefault.ts.md | 2 +- .../docs/modules/sdk/EvalRedeemer.ts.md | 2 +- .../evolution/docs/modules/sdk/Label.ts.md | 2 +- .../evolution/docs/modules/sdk/OutRef.ts.md | 2 +- .../evolution/docs/modules/sdk/PolicyId.ts.md | 2 +- .../docs/modules/sdk/ProtocolParameters.ts.md | 2 +- .../docs/modules/sdk/RewardAddress.ts.md | 2 +- .../evolution/docs/modules/sdk/Script.ts.md | 2 +- .../evolution/docs/modules/sdk/Type.ts.md | 2 +- .../evolution/docs/modules/sdk/UTxO.ts.md | 2 +- .../evolution/docs/modules/sdk/Unit.ts.md | 2 +- .../modules/sdk/builders/SignBuilder.ts.md | 61 +- .../sdk/builders/SignBuilderImpl.ts.md | 51 + .../modules/sdk/builders/SubmitBuilder.ts.md | 79 + .../sdk/builders/SubmitBuilderImpl.ts.md | 45 + .../sdk/builders/TransactionBuilder.ts.md | 231 +- .../sdk/builders/TransactionResult.ts.md | 160 + .../modules/sdk/builders/TxBuilderImpl.ts.md | 2 +- .../docs/modules/sdk/builders/Unfrack.ts.md | 2 +- .../docs/modules/sdk/client/Client.ts.md | 125 +- .../docs/modules/sdk/client/ClientImpl.ts.md | 6 +- .../modules/sdk/provider/Blockfrost.ts.md | 2 +- .../docs/modules/sdk/provider/Koios.ts.md | 2 +- .../docs/modules/sdk/provider/Kupmios.ts.md | 2 +- .../docs/modules/sdk/provider/Maestro.ts.md | 2 +- .../docs/modules/sdk/provider/Provider.ts.md | 2 +- .../docs/modules/sdk/wallet/Derivation.ts.md | 14 +- .../docs/modules/sdk/wallet/Wallet.ts.md | 2 +- .../docs/modules/sdk/wallet/WalletNew.ts.md | 6 +- .../docs/modules/utils/FeeValidation.ts.md | 2 +- .../evolution/docs/modules/utils/Hash.ts.md | 2 +- .../docs/modules/utils/effect-runtime.ts.md | 54 + packages/evolution/package.json | 5 +- .../evolution/src/core/Bip32PrivateKey.ts | 151 +- packages/evolution/src/core/Bip32PublicKey.ts | 23 +- packages/evolution/src/core/Function.ts | 18 +- packages/evolution/src/core/PrivateKey.ts | 51 +- packages/evolution/src/core/VKey.ts | 15 +- packages/evolution/src/index.ts | 1 + .../evolution/src/sdk/builders/SignBuilder.ts | 51 +- .../src/sdk/builders/SignBuilderImpl.ts | 138 + .../src/sdk/builders/SubmitBuilder.ts | 60 + .../src/sdk/builders/SubmitBuilderImpl.ts | 64 + .../src/sdk/builders/TransactionBuilder.ts | 2614 ++++------------- .../src/sdk/builders/TransactionResult.ts | 160 + packages/evolution/src/sdk/builders/index.ts | 6 +- packages/evolution/src/sdk/client/Client.ts | 106 +- .../evolution/src/sdk/client/ClientImpl.ts | 263 +- .../sdk/provider/internal/BlockfrostEffect.ts | 18 +- .../src/sdk/provider/internal/HttpUtils.ts | 8 +- .../evolution/src/sdk/wallet/Derivation.ts | 195 +- packages/evolution/src/sdk/wallet/Wallet.ts | 16 +- .../evolution/src/sdk/wallet/WalletNew.ts | 4 +- .../evolution/src/utils/effect-runtime.ts | 117 + .../TxBuilder.CoinSelectionFailures.test.ts | 59 +- .../test/TxBuilder.EdgeCases.test.ts | 23 +- .../test/TxBuilder.InsufficientChange.test.ts | 119 +- .../test/TxBuilder.Reselection.test.ts | 476 +-- .../TxBuilder.UnfrackChangeHandling.test.ts | 42 +- .../test/TxBuilder.UnfrackDrain.test.ts | 46 +- .../test/TxBuilder.UnfrackMinUTxO.test.ts | 41 +- .../evolution/test/WalletFromSeed.test.ts | 188 +- pnpm-lock.yaml | 47 +- 108 files changed, 3844 insertions(+), 3085 deletions(-) create mode 100644 docs/content/docs/modules/sdk/builders/SignBuilderImpl.mdx create mode 100644 docs/content/docs/modules/sdk/builders/SubmitBuilder.mdx create mode 100644 docs/content/docs/modules/sdk/builders/SubmitBuilderImpl.mdx create mode 100644 docs/content/docs/modules/sdk/builders/TransactionResult.mdx create mode 100644 docs/content/docs/modules/utils/effect-runtime.mdx create mode 100644 packages/evolution/docs/modules/sdk/builders/SignBuilderImpl.ts.md create mode 100644 packages/evolution/docs/modules/sdk/builders/SubmitBuilder.ts.md create mode 100644 packages/evolution/docs/modules/sdk/builders/SubmitBuilderImpl.ts.md create mode 100644 packages/evolution/docs/modules/sdk/builders/TransactionResult.ts.md create mode 100644 packages/evolution/docs/modules/utils/effect-runtime.ts.md create mode 100644 packages/evolution/src/sdk/builders/SignBuilderImpl.ts create mode 100644 packages/evolution/src/sdk/builders/SubmitBuilder.ts create mode 100644 packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts create mode 100644 packages/evolution/src/sdk/builders/TransactionResult.ts create mode 100644 packages/evolution/src/utils/effect-runtime.ts diff --git a/docs/content/docs/modules/core/Function.mdx b/docs/content/docs/modules/core/Function.mdx index 51e69197..638e7639 100644 --- a/docs/content/docs/modules/core/Function.mdx +++ b/docs/content/docs/modules/core/Function.mdx @@ -121,7 +121,7 @@ export declare const makeCBOREncodeEither: , ErrorClass: ErrorCtor, defaultOptions?: CBOR.CodecOptions -) => (input: A, options?: CBOR.CodecOptions) => Either.Either +) => (value: A, options?: CBOR.CodecOptions) => Either.Either ``` ## makeCBOREncodeHexEither diff --git a/docs/content/docs/modules/core/Value.mdx b/docs/content/docs/modules/core/Value.mdx index 9ea8f3c2..c0f1cd21 100644 --- a/docs/content/docs/modules/core/Value.mdx +++ b/docs/content/docs/modules/core/Value.mdx @@ -26,6 +26,8 @@ parent: Modules - [arbitrary](#arbitrary) - [model](#model) - [ValueCDDL (type alias)](#valuecddl-type-alias) +- [ordering](#ordering) + - [geq](#geq) - [parsing](#parsing) - [fromCBORBytes](#fromcborbytes) - [fromCBORHex](#fromcborhex) @@ -168,6 +170,21 @@ export type ValueCDDL = typeof FromCDDL.Type Added in v2.0.0 +# ordering + +## geq + +Check if Value a is greater than or equal to Value b. +This means after subtracting b from a, the result would not be negative. + +**Signature** + +```ts +export declare const geq: (a: Value, b: Value) => boolean +``` + +Added in v2.0.0 + # parsing ## fromCBORBytes diff --git a/docs/content/docs/modules/sdk/Credential.mdx b/docs/content/docs/modules/sdk/Credential.mdx index 1cbb8f7b..f0689c57 100644 --- a/docs/content/docs/modules/sdk/Credential.mdx +++ b/docs/content/docs/modules/sdk/Credential.mdx @@ -1,6 +1,6 @@ --- title: sdk/Credential.ts -nav_order: 135 +nav_order: 139 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Datum.mdx b/docs/content/docs/modules/sdk/Datum.mdx index c8029612..e3128364 100644 --- a/docs/content/docs/modules/sdk/Datum.mdx +++ b/docs/content/docs/modules/sdk/Datum.mdx @@ -1,6 +1,6 @@ --- title: sdk/Datum.ts -nav_order: 136 +nav_order: 140 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Delegation.mdx b/docs/content/docs/modules/sdk/Delegation.mdx index df19e34f..74858817 100644 --- a/docs/content/docs/modules/sdk/Delegation.mdx +++ b/docs/content/docs/modules/sdk/Delegation.mdx @@ -1,6 +1,6 @@ --- title: sdk/Delegation.ts -nav_order: 137 +nav_order: 141 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Devnet/Devnet.mdx b/docs/content/docs/modules/sdk/Devnet/Devnet.mdx index 1937ef4c..29cbad5a 100644 --- a/docs/content/docs/modules/sdk/Devnet/Devnet.mdx +++ b/docs/content/docs/modules/sdk/Devnet/Devnet.mdx @@ -1,6 +1,6 @@ --- title: sdk/Devnet/Devnet.ts -nav_order: 138 +nav_order: 142 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Devnet/DevnetDefault.mdx b/docs/content/docs/modules/sdk/Devnet/DevnetDefault.mdx index 8be3b4c1..9a88ecf7 100644 --- a/docs/content/docs/modules/sdk/Devnet/DevnetDefault.mdx +++ b/docs/content/docs/modules/sdk/Devnet/DevnetDefault.mdx @@ -1,6 +1,6 @@ --- title: sdk/Devnet/DevnetDefault.ts -nav_order: 139 +nav_order: 143 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/EvalRedeemer.mdx b/docs/content/docs/modules/sdk/EvalRedeemer.mdx index 9218e779..66881f96 100644 --- a/docs/content/docs/modules/sdk/EvalRedeemer.mdx +++ b/docs/content/docs/modules/sdk/EvalRedeemer.mdx @@ -1,6 +1,6 @@ --- title: sdk/EvalRedeemer.ts -nav_order: 140 +nav_order: 144 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Label.mdx b/docs/content/docs/modules/sdk/Label.mdx index e0d922ff..401d9c5f 100644 --- a/docs/content/docs/modules/sdk/Label.mdx +++ b/docs/content/docs/modules/sdk/Label.mdx @@ -1,6 +1,6 @@ --- title: sdk/Label.ts -nav_order: 141 +nav_order: 145 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/OutRef.mdx b/docs/content/docs/modules/sdk/OutRef.mdx index 20184c8c..a3d1e7d9 100644 --- a/docs/content/docs/modules/sdk/OutRef.mdx +++ b/docs/content/docs/modules/sdk/OutRef.mdx @@ -1,6 +1,6 @@ --- title: sdk/OutRef.ts -nav_order: 142 +nav_order: 146 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/PolicyId.mdx b/docs/content/docs/modules/sdk/PolicyId.mdx index 20dbf9f6..4e7cd895 100644 --- a/docs/content/docs/modules/sdk/PolicyId.mdx +++ b/docs/content/docs/modules/sdk/PolicyId.mdx @@ -1,6 +1,6 @@ --- title: sdk/PolicyId.ts -nav_order: 143 +nav_order: 147 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/ProtocolParameters.mdx b/docs/content/docs/modules/sdk/ProtocolParameters.mdx index e40d7b46..c3b03bd7 100644 --- a/docs/content/docs/modules/sdk/ProtocolParameters.mdx +++ b/docs/content/docs/modules/sdk/ProtocolParameters.mdx @@ -1,6 +1,6 @@ --- title: sdk/ProtocolParameters.ts -nav_order: 144 +nav_order: 148 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/RewardAddress.mdx b/docs/content/docs/modules/sdk/RewardAddress.mdx index e70498e8..46f3c2f7 100644 --- a/docs/content/docs/modules/sdk/RewardAddress.mdx +++ b/docs/content/docs/modules/sdk/RewardAddress.mdx @@ -1,6 +1,6 @@ --- title: sdk/RewardAddress.ts -nav_order: 150 +nav_order: 154 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Script.mdx b/docs/content/docs/modules/sdk/Script.mdx index 9dc6c484..bdfe562c 100644 --- a/docs/content/docs/modules/sdk/Script.mdx +++ b/docs/content/docs/modules/sdk/Script.mdx @@ -1,6 +1,6 @@ --- title: sdk/Script.ts -nav_order: 151 +nav_order: 155 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Type.mdx b/docs/content/docs/modules/sdk/Type.mdx index 58d93023..03c39a03 100644 --- a/docs/content/docs/modules/sdk/Type.mdx +++ b/docs/content/docs/modules/sdk/Type.mdx @@ -1,6 +1,6 @@ --- title: sdk/Type.ts -nav_order: 152 +nav_order: 156 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/UTxO.mdx b/docs/content/docs/modules/sdk/UTxO.mdx index ee9ce64b..9783c1d1 100644 --- a/docs/content/docs/modules/sdk/UTxO.mdx +++ b/docs/content/docs/modules/sdk/UTxO.mdx @@ -1,6 +1,6 @@ --- title: sdk/UTxO.ts -nav_order: 154 +nav_order: 158 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/Unit.mdx b/docs/content/docs/modules/sdk/Unit.mdx index 42b0ba96..3c445ec7 100644 --- a/docs/content/docs/modules/sdk/Unit.mdx +++ b/docs/content/docs/modules/sdk/Unit.mdx @@ -1,6 +1,6 @@ --- title: sdk/Unit.ts -nav_order: 153 +nav_order: 157 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/builders/SignBuilder.mdx b/docs/content/docs/modules/sdk/builders/SignBuilder.mdx index 34bf2be4..2fb69a08 100644 --- a/docs/content/docs/modules/sdk/builders/SignBuilder.mdx +++ b/docs/content/docs/modules/sdk/builders/SignBuilder.mdx @@ -10,76 +10,57 @@ parent: Modules

Table of contents

-- [utils](#utils) +- [interfaces](#interfaces) - [SignBuilder (interface)](#signbuilder-interface) - [SignBuilderEffect (interface)](#signbuildereffect-interface) - - [SubmitBuilder (interface)](#submitbuilder-interface) - - [SubmitBuilderEffect (interface)](#submitbuildereffect-interface) --- -# utils +# interfaces ## SignBuilder (interface) +SignBuilder extends TransactionResultBase with signing capabilities. + +Only available when the client has a signing wallet (seed, private key, or API wallet). +Provides access to unsigned transaction (via base interface) and signing operations. + **Signature** ```ts -export interface SignBuilder extends EffectToPromiseAPI { +export interface SignBuilder extends TransactionResultBase, EffectToPromiseAPI { readonly Effect: SignBuilderEffect } ``` +Added in v2.0.0 + ## SignBuilderEffect (interface) +Effect-based API for SignBuilder operations. + +Includes all TransactionResultBase.Effect methods plus signing-specific operations. + **Signature** ```ts export interface SignBuilderEffect { - // Main signing method - produces a fully signed transaction ready for submission - readonly sign: () => Effect.Effect + // Base transaction methods (from TransactionResultBase) + readonly toTransaction: () => Effect.Effect + readonly toTransactionWithFakeWitnesses: () => Effect.Effect + readonly estimateFee: () => Effect.Effect - // Add external witness and proceed to submission + // Signing methods + readonly sign: () => Effect.Effect readonly signWithWitness: ( witnessSet: TransactionWitnessSet.TransactionWitnessSet ) => Effect.Effect - - // Assemble multiple witnesses into a complete transaction ready for submission readonly assemble: ( witnesses: ReadonlyArray ) => Effect.Effect - - // Partial signing - creates witness without advancing to submission (useful for multi-sig) readonly partialSign: () => Effect.Effect - - // Get witness set without signing (for inspection) readonly getWitnessSet: () => Effect.Effect - - // Get the unsigned transaction (for inspection) - readonly toTransaction: () => Effect.Effect - - // Get the transaction with fake witnesses (for fee validation) - readonly toTransactionWithFakeWitnesses: () => Effect.Effect -} -``` - -## SubmitBuilder (interface) - -**Signature** - -```ts -export interface SubmitBuilder extends EffectToPromiseAPI { - readonly Effect: SubmitBuilderEffect - readonly witnessSet: TransactionWitnessSet.TransactionWitnessSet } ``` -## SubmitBuilderEffect (interface) - -**Signature** - -```ts -export interface SubmitBuilderEffect { - readonly submit: () => Effect.Effect -} -``` +Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/SignBuilderImpl.mdx b/docs/content/docs/modules/sdk/builders/SignBuilderImpl.mdx new file mode 100644 index 00000000..5e77c21b --- /dev/null +++ b/docs/content/docs/modules/sdk/builders/SignBuilderImpl.mdx @@ -0,0 +1,51 @@ +--- +title: sdk/builders/SignBuilderImpl.ts +nav_order: 130 +parent: Modules +--- + +## SignBuilderImpl overview + +SignBuilder Implementation + +Handles transaction signing by delegating to the wallet's signTx Effect method. +The SignBuilder is responsible for: + +1. Providing the transaction and UTxO context to the wallet +2. Managing the transition from unsigned to signed transaction +3. Creating the SubmitBuilder for transaction submission + +The actual signing logic (determining required signers, creating witnesses) +is the wallet's responsibility. + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [makeSignBuilder](#makesignbuilder) + +--- + +# constructors + +## makeSignBuilder + +Create a SignBuilder instance for a built transaction. + +**Signature** + +```ts +export declare const makeSignBuilder: (params: { + transaction: Transaction.Transaction + transactionWithFakeWitnesses: Transaction.Transaction + fee: bigint + utxos: ReadonlyArray + provider: Provider.Provider + wallet: Wallet +}) => SignBuilder +``` + +Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/SubmitBuilder.mdx b/docs/content/docs/modules/sdk/builders/SubmitBuilder.mdx new file mode 100644 index 00000000..122c2612 --- /dev/null +++ b/docs/content/docs/modules/sdk/builders/SubmitBuilder.mdx @@ -0,0 +1,79 @@ +--- +title: sdk/builders/SubmitBuilder.ts +nav_order: 131 +parent: Modules +--- + +## SubmitBuilder overview + +SubmitBuilder - Final stage of transaction lifecycle + +Represents a signed transaction ready for submission to the blockchain. +Provides the submit() method to broadcast the transaction and retrieve the transaction hash. + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [interfaces](#interfaces) + - [SubmitBuilder (interface)](#submitbuilder-interface) + - [SubmitBuilderEffect (interface)](#submitbuildereffect-interface) + +--- + +# interfaces + +## SubmitBuilder (interface) + +SubmitBuilder - represents a signed transaction ready for submission. + +The final stage in the transaction lifecycle after building and signing. +Provides the submit() method to broadcast the transaction to the blockchain +and retrieve the transaction hash. + +**Signature** + +```ts +export interface SubmitBuilder extends EffectToPromiseAPI { + /** + * Effect-based API for compositional workflows. + * + * @since 2.0.0 + */ + readonly Effect: SubmitBuilderEffect + + /** + * The witness set containing all signatures for this transaction. + * + * Can be used to inspect the signatures or combine with other witness sets + * for multi-party signing scenarios. + * + * @since 2.0.0 + */ + readonly witnessSet: TransactionWitnessSet.TransactionWitnessSet +} +``` + +Added in v2.0.0 + +## SubmitBuilderEffect (interface) + +Effect-based API for SubmitBuilder operations. + +**Signature** + +```ts +export interface SubmitBuilderEffect { + /** + * Submit the signed transaction to the blockchain via the provider. + * + * @returns Effect resolving to the transaction hash + * @since 2.0.0 + */ + readonly submit: () => Effect.Effect +} +``` + +Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/SubmitBuilderImpl.mdx b/docs/content/docs/modules/sdk/builders/SubmitBuilderImpl.mdx new file mode 100644 index 00000000..daf8587f --- /dev/null +++ b/docs/content/docs/modules/sdk/builders/SubmitBuilderImpl.mdx @@ -0,0 +1,45 @@ +--- +title: sdk/builders/SubmitBuilderImpl.ts +nav_order: 132 +parent: Modules +--- + +## SubmitBuilderImpl overview + +SubmitBuilder Implementation + +Handles transaction submission by delegating to the provider's submitTx method. +The SubmitBuilder is responsible for: + +1. Converting the signed transaction to CBOR hex format +2. Submitting to the provider's Effect.submitTx +3. Returning the transaction hash + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [makeSubmitBuilder](#makesubmitbuilder) + +--- + +# constructors + +## makeSubmitBuilder + +Create a SubmitBuilder instance for a signed transaction. + +**Signature** + +```ts +export declare const makeSubmitBuilder: ( + signedTransaction: Transaction.Transaction, + witnessSet: TransactionWitnessSet.TransactionWitnessSet, + provider: Provider.Provider +) => SubmitBuilder +``` + +Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx b/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx index ee84701c..b2ee14d5 100644 --- a/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx +++ b/docs/content/docs/modules/sdk/builders/TransactionBuilder.mdx @@ -1,6 +1,6 @@ --- title: sdk/builders/TransactionBuilder.ts -nav_order: 130 +nav_order: 133 parent: Modules --- @@ -38,6 +38,9 @@ double-spending. UTxOs can come from any source (wallet, DeFi protocols, other p - [constructors](#constructors) - [makeTxBuilder](#maketxbuilder) - [context](#context) + - [AvailableUtxosTag (class)](#availableutxostag-class) + - [ChangeAddressTag (class)](#changeaddresstag-class) + - [ProtocolParametersTag (class)](#protocolparameterstag-class) - [TxContext (class)](#txcontext-class) - [TxContextData (interface)](#txcontextdata-interface) - [errors](#errors) @@ -107,34 +110,49 @@ Added in v2.0.0 Configuration for TransactionBuilder. Immutable configuration passed to builder at creation time. -Contains: +Wallet-centric design (when wallet provided): -- Protocol parameters for fee calculation -- Change address for leftover funds -- Available UTxOs for coin selection +- Wallet provides change address (via wallet.Effect.address()) +- Provider + Wallet provide available UTxOs (via provider.Effect.getUtxos(wallet.address)) +- Override per-build via BuildOptions if needed + +Manual mode (no wallet): + +- Must provide changeAddress and availableUtxos in BuildOptions for each build +- Used for read-only scenarios or advanced use cases **Signature** ```ts export interface TxBuilderConfig { - readonly protocolParameters: ProtocolParameters - /** - * Address to send change (leftover assets) to. - * This is required for proper transaction balancing. + * Optional wallet provides: + * - Change address via wallet.Effect.address() + * - Available UTxOs via wallet.Effect.address() + provider.Effect.getUtxos() + * - Signing capability via wallet.Effect.signTx() (SigningWallet and ApiWallet only) + * + * When provided: Automatic change address and UTxO resolution. + * When omitted: Must provide changeAddress and availableUtxos in BuildOptions. + * + * ReadOnlyWallet: For read-only clients that can build but not sign transactions. + * SigningWallet/ApiWallet: For signing clients with full transaction signing capability. + * + * Override per-build via BuildOptions.changeAddress and BuildOptions.availableUtxos. */ - readonly changeAddress: string + readonly wallet?: WalletNew.SigningWallet | WalletNew.ApiWallet | WalletNew.ReadOnlyWallet /** - * UTxOs available for coin selection. - * These can be from a wallet, another user, or any other source. - * Coin selection will automatically select from these UTxOs to cover - * required outputs + fees, excluding any already collected via collectFrom(). + * Optional provider for: + * - Fetching UTxOs for the wallet's address (provider.Effect.getUtxos) + * - Transaction submission (provider.Effect.submitTx) + * - Protocol parameters + * + * Works together with wallet to provide everything needed for transaction building. + * When wallet is omitted, provider is only used if you call provider methods directly. */ - readonly availableUtxos: ReadonlyArray + readonly provider?: Provider.Provider // Future fields: - // readonly provider?: any // Provider interface for blockchain communication // readonly costModels?: Uint8Array // Cost models for script evaluation } ``` @@ -151,16 +169,75 @@ The builder accumulates chainable method calls as deferred ProgramSteps. Calling creates fresh state (new Refs) and executes all accumulated programs sequentially, ensuring no state pollution between invocations. +Generic type parameter TResult determines what build() returns: + +- SignBuilder (default): When wallet has signing capability +- TransactionResultBase: When wallet is read-only + **Signature** ```ts -export declare const makeTxBuilder: (config: TxBuilderConfig) => TransactionBuilder +export declare const makeTxBuilder: (config: TxBuilderConfig) => TransactionBuilder ``` Added in v2.0.0 # context +## AvailableUtxosTag (class) + +Resolved available UTxOs for the current build. +This is resolved once at the start of build() from either: + +- BuildOptions.availableUtxos (per-transaction override) +- provider.Effect.getUtxos(wallet.address) (default from wallet + provider) + +Available to all phase functions via Effect Context. + +**Signature** + +```ts +export declare class AvailableUtxosTag +``` + +Added in v2.0.0 + +## ChangeAddressTag (class) + +Resolved change address for the current build. +This is resolved once at the start of build() from either: + +- BuildOptions.changeAddress (per-transaction override) +- TxBuilderConfig.wallet.Effect.address() (default from wallet) + +Available to all phase functions via Effect Context. + +**Signature** + +```ts +export declare class ChangeAddressTag +``` + +Added in v2.0.0 + +## ProtocolParametersTag (class) + +Resolved protocol parameters for the current build. +This is resolved once at the start of build() from either: + +- BuildOptions.protocolParameters (per-transaction override) +- provider.Effect.getProtocolParameters() (fetched from provider) + +Available to all phase functions via Effect Context. + +**Signature** + +```ts +export declare class ProtocolParametersTag +``` + +Added in v2.0.0 + ## TxContext (class) Single Context service providing all transaction building data to programs. @@ -248,6 +325,12 @@ Key Design Principle: Builder instance never mutates. Programs are deferred Effects that execute later. Each build() creates fresh TxBuilderState, executes programs, returns result. +Generic Type Parameter: +TResult determines the return type of build() methods: + +- SignBuilder: When wallet has signing capability (SigningClient) +- TransactionResultBase: When wallet is read-only (ReadOnlyClient) + Usage Pattern: ```typescript @@ -265,7 +348,7 @@ const signBuilder2 = await builder.build() **Signature** ```ts -export interface TransactionBuilder { +export interface TransactionBuilder { // ============================================================================ // Chainable Builder Methods - Create ProgramSteps, return same builder // ============================================================================ @@ -279,7 +362,7 @@ export interface TransactionBuilder { * @since 2.0.0 * @category builder-methods */ - readonly payToAddress: (params: PayToAddressParams) => TransactionBuilder + readonly payToAddress: (params: PayToAddressParams) => TransactionBuilder /** * Specify transaction inputs from provided UTxOs. @@ -290,7 +373,7 @@ export interface TransactionBuilder { * @since 2.0.0 * @category builder-methods */ - readonly collectFrom: (params: CollectFromParams) => TransactionBuilder + readonly collectFrom: (params: CollectFromParams) => TransactionBuilder // Future expansion points for other operations: // readonly mintTokens: (params: MintTokensParams) => TransactionBuilder @@ -309,10 +392,14 @@ export interface TransactionBuilder { * Creates fresh state and runs all accumulated ProgramSteps sequentially. * Can be called multiple times on the same builder instance with independent results. * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) + * * @since 2.0.0 * @category completion-methods */ - readonly build: (options?: BuildOptions) => Promise + readonly build: (options?: BuildOptions) => Promise /** * Execute all queued operations and return a signing-ready transaction via Effect. @@ -320,25 +407,43 @@ export interface TransactionBuilder { * Creates fresh state and runs all accumulated ProgramSteps sequentially. * Suitable for Effect-TS compositional workflows and error handling. * + * Error types include WalletError and ProviderError from config Effects. + * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) + * * @since 2.0.0 * @category completion-methods */ readonly buildEffect: ( options?: BuildOptions - ) => Effect.Effect + ) => Effect.Effect< + TResult, + TransactionBuilderError | EvaluationError | WalletNew.WalletError | Provider.ProviderError, + unknown + > /** * Execute all queued operations with explicit error handling via Either. * * Creates fresh state and runs all accumulated ProgramSteps sequentially. - * Returns Either for pattern-matched error recovery. + * Returns Either for pattern-matched error recovery. + * + * Error types include WalletError and ProviderError from config Effects. + * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) * * @since 2.0.0 * @category completion-methods */ readonly buildEither: ( options?: BuildOptions - ) => Promise> + ) => Promise< + Either + > // ============================================================================ // Transaction Chaining Methods - Multi-transaction workflows @@ -600,6 +705,29 @@ Added in v2.0.0 ````ts export interface BuildOptions { + /** + * Override protocol parameters for this specific transaction build. + * + * By default, fetches from provider during build(). + * Provide this to use different protocol parameters for testing or special cases. + * + * Use cases: + * - Testing with different fee parameters + * - Simulating future protocol changes + * - Using cached parameters to avoid provider fetch + * + * Example: + * ```typescript + * // Test with custom fee parameters + * builder.build({ + * protocolParameters: { ...params, minFeeCoefficient: 50n, minFeeConstant: 200000n } + * }) + * ``` + * + * @since 2.0.0 + */ + readonly protocolParameters?: ProtocolParameters + /** * Coin selection strategy for automatic input selection. * @@ -624,6 +752,56 @@ export interface BuildOptions { // Change Handling Configuration // ============================================================================ + /** + * Override the change address for this specific transaction build. + * + * By default, uses wallet.Effect.address() from TxBuilderConfig. + * Provide this to use a different address for change outputs. + * + * Use cases: + * - Multi-address wallet (use account index 5 for change) + * - Different change address per transaction + * - Multi-sig workflows where change address varies + * - Testing with different addresses + * + * Example: + * ```typescript + * // Use different account for change + * builder.build({ changeAddress: wallet.addresses[5] }) + * + * // Custom address + * builder.build({ changeAddress: "addr_test1..." }) + * ``` + * + * @since 2.0.0 + */ + readonly changeAddress?: string + + /** + * Override the available UTxOs for this specific transaction build. + * + * By default, fetches UTxOs from provider.Effect.getUtxos(wallet.address). + * Provide this to use a specific set of UTxOs for coin selection. + * + * Use cases: + * - Use UTxOs from specific account index + * - Pre-filtered UTxO set + * - Testing with known UTxO set + * - Multi-address UTxO aggregation + * + * Example: + * ```typescript + * // Use UTxOs from specific account + * builder.build({ availableUtxos: utxosFromAccount5 }) + * + * // Combine UTxOs from multiple addresses + * builder.build({ availableUtxos: [...utxos1, ...utxos2] }) + * ``` + * + * @since 2.0.0 + */ + readonly availableUtxos?: ReadonlyArray + /** * # Change Handling Strategy Matrix * @@ -739,12 +917,11 @@ export interface BuildOptions { readonly useStateMachine?: boolean /** - * **EXPERIMENTAL**: Use V3 4-phase state machine * - * When true, uses V3's simplified 4-phase state machine: + * When true, uses simplified 4-phase state machine: * - selection → changeValidation → balanceVerification → fallback → complete * - * V3 shares TxContext with V2 but uses mathematical validation approach. + * shares TxContext with V2 but uses mathematical validation approach. * * @experimental * @default false diff --git a/docs/content/docs/modules/sdk/builders/TransactionResult.mdx b/docs/content/docs/modules/sdk/builders/TransactionResult.mdx new file mode 100644 index 00000000..f34cc39f --- /dev/null +++ b/docs/content/docs/modules/sdk/builders/TransactionResult.mdx @@ -0,0 +1,160 @@ +--- +title: sdk/builders/TransactionResult.ts +nav_order: 134 +parent: Modules +--- + +## TransactionResult overview + +TransactionResult - Base interface for transaction building results + +Provides core functionality available to all transaction builders regardless +of signing capability. This enables type-safe differentiation between +read-only clients (can build but not sign) and signing clients (can build and sign). + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [makeTransactionResult](#maketransactionresult) +- [interfaces](#interfaces) + - [TransactionResultBase (interface)](#transactionresultbase-interface) + +--- + +# constructors + +## makeTransactionResult + +Create a TransactionResultBase instance for a built transaction without signing capability. + +Used by ReadOnlyClient which can build transactions but cannot sign them. +Provides access to the unsigned transaction, fake-witness transaction for fee validation, +and fee estimation. + +**Signature** + +```ts +export declare const makeTransactionResult: (params: { + transaction: Transaction.Transaction + transactionWithFakeWitnesses: Transaction.Transaction + fee: bigint +}) => TransactionResultBase +``` + +Added in v2.0.0 + +# interfaces + +## TransactionResultBase (interface) + +Base result interface for built transactions. + +Available on all transaction builders regardless of signing capability. +Provides access to the unsigned transaction, fee estimates, and transaction +with fake witnesses for size validation. + +**Signature** + +````ts +export interface TransactionResultBase { + /** + * Get the unsigned transaction. + * + * This transaction has a complete body but no witness set (signatures). + * Can be serialized to CBOR for external signing (hardware wallets, browser extensions, etc.) + * + * @returns Promise resolving to the unsigned transaction + * + * @example + * ```typescript + * const result = await readOnlyClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5_000_000n }) + * .build() + * + * const unsignedTx = await result.toTransaction() + * const txCbor = Transaction.toCBORHex(unsignedTx) + * // Export for external signing + * ``` + * + * @since 2.0.0 + * @category accessors + */ + readonly toTransaction: () => Promise + + /** + * Get the transaction with fake witnesses for fee validation. + * + * This transaction includes fake witness sets (294 bytes each) to accurately + * calculate the final transaction size and fees. Useful for validating that + * the calculated fee is sufficient for the final signed transaction. + * + * @returns Promise resolving to the transaction with fake witnesses + * + * @since 2.0.0 + * @category accessors + */ + readonly toTransactionWithFakeWitnesses: () => Promise + + /** + * Get the calculated transaction fee in lovelace. + * + * This is the fee that was calculated during the build process based on + * the transaction size (including fake witnesses) and protocol parameters. + * + * @returns Promise resolving to the transaction fee in lovelace + * + * @example + * ```typescript + * const result = await client.newTx() + * .payToAddress({ address: "addr...", lovelace: 5_000_000n }) + * .build() + * + * const fee = await result.estimateFee() + * console.log(`Transaction fee: ${fee} lovelace`) + * ``` + * + * @since 2.0.0 + * @category accessors + */ + readonly estimateFee: () => Promise + + /** + * Effect-based API for compositional workflows. + * + * Provides the same functionality as the Promise-based methods but returns + * Effect values for use in Effect-TS workflows with proper error handling + * and composition. + * + * @since 2.0.0 + * @category effects + */ + readonly Effect: { + /** + * Get the unsigned transaction as an Effect. + * + * @since 2.0.0 + */ + readonly toTransaction: () => Effect.Effect + + /** + * Get the transaction with fake witnesses as an Effect. + * + * @since 2.0.0 + */ + readonly toTransactionWithFakeWitnesses: () => Effect.Effect + + /** + * Get the calculated fee as an Effect. + * + * @since 2.0.0 + */ + readonly estimateFee: () => Effect.Effect + } +} +```` + +Added in v2.0.0 diff --git a/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx b/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx index 5d870494..be5a421a 100644 --- a/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx +++ b/docs/content/docs/modules/sdk/builders/TxBuilderImpl.mdx @@ -1,6 +1,6 @@ --- title: sdk/builders/TxBuilderImpl.ts -nav_order: 131 +nav_order: 135 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/builders/Unfrack.mdx b/docs/content/docs/modules/sdk/builders/Unfrack.mdx index f94178fd..f1166327 100644 --- a/docs/content/docs/modules/sdk/builders/Unfrack.mdx +++ b/docs/content/docs/modules/sdk/builders/Unfrack.mdx @@ -1,6 +1,6 @@ --- title: sdk/builders/Unfrack.ts -nav_order: 132 +nav_order: 136 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/client/Client.mdx b/docs/content/docs/modules/sdk/client/Client.mdx index af3a96d0..135f9c4d 100644 --- a/docs/content/docs/modules/sdk/client/Client.mdx +++ b/docs/content/docs/modules/sdk/client/Client.mdx @@ -1,6 +1,6 @@ --- title: sdk/client/Client.ts -nav_order: 133 +nav_order: 137 parent: Modules --- @@ -24,6 +24,7 @@ parent: Modules - [MinimalClient (interface)](#minimalclient-interface) - [MinimalClientEffect (interface)](#minimalclienteffect-interface) - [NetworkId (type alias)](#networkid-type-alias) + - [PrivateKeyWalletConfig (interface)](#privatekeywalletconfig-interface) - [ProviderConfig (type alias)](#providerconfig-type-alias) - [ProviderOnlyClient (type alias)](#provideronlyclient-type-alias) - [ReadOnlyClient (type alias)](#readonlyclient-type-alias) @@ -156,13 +157,21 @@ export interface MinimalClient { config: T ) => T extends SeedWalletConfig ? SigningWalletClient - : T extends ApiWalletConfig - ? ApiWalletClient - : ReadOnlyWalletClient + : T extends PrivateKeyWalletConfig + ? SigningWalletClient + : T extends ApiWalletConfig + ? ApiWalletClient + : ReadOnlyWalletClient readonly attach: ( providerConfig: ProviderConfig, walletConfig: TW - ) => TW extends SeedWalletConfig ? SigningClient : TW extends ApiWalletConfig ? SigningClient : ReadOnlyClient + ) => TW extends SeedWalletConfig + ? SigningClient + : TW extends PrivateKeyWalletConfig + ? SigningClient + : TW extends ApiWalletConfig + ? SigningClient + : ReadOnlyClient // Effect namespace for methods with side effects only readonly Effect: MinimalClientEffect } @@ -188,6 +197,19 @@ export interface MinimalClientEffect { export type NetworkId = "mainnet" | "preprod" | "preview" | number ``` +## PrivateKeyWalletConfig (interface) + +**Signature** + +```ts +export interface PrivateKeyWalletConfig { + readonly type: "private-key" + readonly paymentKey: string // bech32 ed25519e_sk + readonly stakeKey?: string // bech32 ed25519e_sk (optional, for Base addresses) + readonly addressType?: "Base" | "Enterprise" +} +``` + ## ProviderConfig (type alias) **Signature** @@ -207,7 +229,13 @@ export type ProviderOnlyClient = EffectToPromiseAPI & { // Combinator methods (pure, no side effects) with type-aware conditional return type readonly attachWallet: ( config: T - ) => T extends SeedWalletConfig ? SigningClient : T extends ApiWalletConfig ? SigningClient : ReadOnlyClient + ) => T extends SeedWalletConfig + ? SigningClient + : T extends PrivateKeyWalletConfig + ? SigningClient + : T extends ApiWalletConfig + ? SigningClient + : ReadOnlyClient // Effect namespace - includes all provider methods as Effects readonly Effect: Provider.ProviderEffect } @@ -217,15 +245,48 @@ export type ProviderOnlyClient = EffectToPromiseAPI & { ReadOnlyClient - can query blockchain + wallet address operations +ReadOnlyClient cannot sign transactions, so newTx() returns a TransactionBuilder +that yields TransactionResultBase (unsigned transaction only). + **Signature** -```ts +````ts export type ReadOnlyClient = EffectToPromiseAPI & { - readonly newTx: (utxos?: ReadonlyArray) => any // TODO: Change to ReadOnlyTransactionBuilder when implementing tx builder + /** + * Create a new transaction builder for read-only operations. + * + * Returns a TransactionBuilder that builds unsigned transactions. + * The build() methods return TransactionResultBase which provides: + * - `.toTransaction()` - Get the unsigned transaction + * - `.toTransactionWithFakeWitnesses()` - Get transaction with fake witnesses for fee validation + * - `.estimateFee()` - Get the calculated fee + * + * @param utxos - Optional UTxOs to use for coin selection. If not provided, wallet UTxOs will be fetched automatically when build() is called. + * @returns A new TransactionBuilder instance configured with cached protocol parameters and wallet change address. + * + * @example + * ```typescript + * // Build unsigned transaction + * const result = await readOnlyClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5000000n }) + * .build() + * + * // Get unsigned transaction for external signing + * const unsignedTx = await result.toTransaction() + * const txCbor = Transaction.toCBORHex(unsignedTx) + * + * // Get fee estimate + * const fee = await result.estimateFee() + * ``` + * + * @since 2.0.0 + * @category transaction-building + */ + readonly newTx: (utxos?: ReadonlyArray) => TransactionBuilder // Effect namespace - includes all provider + wallet methods as Effects readonly Effect: ReadOnlyClientEffect } -``` +```` ## ReadOnlyClientEffect (interface) @@ -350,15 +411,53 @@ export interface SeedWalletConfig { SigningClient - full functionality: query blockchain + sign + submit +SigningClient has wallet signing capability, so newTx() returns a TransactionBuilder +that yields SignBuilder (can sign and submit transactions). + **Signature** -```ts +````ts export type SigningClient = EffectToPromiseAPI & { - readonly newTx: (utxos?: ReadonlyArray) => any // TODO: Change to ReadOnlyTransactionBuilder when implementing tx builder + /** + * Create a new transaction builder with signing capability. + * + * Returns a TransactionBuilder that can build and sign transactions. + * The build() methods return SignBuilder which provides: + * - `.sign()` - Sign and prepare for submission + * - `.toTransaction()` - Get the unsigned transaction + * - `.toTransactionWithFakeWitnesses()` - Get transaction with fake witnesses for fee validation + * - `.estimateFee()` - Get the calculated fee + * - `.partialSign()` - Create partial signature for multi-sig + * - `.assemble()` - Combine multiple signatures + * + * UTxOs for coin selection are fetched automatically from the wallet when build() is called. + * You can override UTxOs per-build using BuildOptions.availableUtxos. + * + * @returns A new TransactionBuilder instance configured with cached protocol parameters and wallet change address. + * + * @example + * ```typescript + * // Build and sign transaction + * const signBuilder = await signingClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5000000n }) + * .build() + * + * // Sign and submit + * const submitBuilder = await signBuilder.sign() + * const txHash = await submitBuilder.submit() + * + * // Or get unsigned transaction + * const unsignedTx = await signBuilder.toTransaction() + * ``` + * + * @since 2.0.0 + * @category transaction-building + */ + readonly newTx: () => TransactionBuilder // Effect namespace - includes all provider + wallet methods as Effects readonly Effect: SigningClientEffect } -``` +```` ## SigningClientEffect (interface) @@ -396,5 +495,5 @@ export type SigningWalletClient = EffectToPromiseAPI & { **Signature** ```ts -export type WalletConfig = SeedWalletConfig | ReadOnlyWalletConfig | ApiWalletConfig +export type WalletConfig = SeedWalletConfig | PrivateKeyWalletConfig | ReadOnlyWalletConfig | ApiWalletConfig ``` diff --git a/docs/content/docs/modules/sdk/client/ClientImpl.mdx b/docs/content/docs/modules/sdk/client/ClientImpl.mdx index 6c6b4b33..a72bb2bf 100644 --- a/docs/content/docs/modules/sdk/client/ClientImpl.mdx +++ b/docs/content/docs/modules/sdk/client/ClientImpl.mdx @@ -1,6 +1,6 @@ --- title: sdk/client/ClientImpl.ts -nav_order: 134 +nav_order: 138 parent: Modules --- @@ -51,6 +51,10 @@ export declare function createClient(config: { wallet: ReadOnlyWalletConfig }): ReadOnlyWalletClient export declare function createClient(config: { network?: NetworkId; wallet: SeedWalletConfig }): SigningWalletClient +export declare function createClient(config: { + network?: NetworkId + wallet: PrivateKeyWalletConfig +}): SigningWalletClient export declare function createClient(config: { network?: NetworkId; wallet: ApiWalletConfig }): ApiWalletClient export declare function createClient(config?: { network?: NetworkId }): MinimalClient ``` diff --git a/docs/content/docs/modules/sdk/provider/Blockfrost.mdx b/docs/content/docs/modules/sdk/provider/Blockfrost.mdx index cf29413c..79bf9115 100644 --- a/docs/content/docs/modules/sdk/provider/Blockfrost.mdx +++ b/docs/content/docs/modules/sdk/provider/Blockfrost.mdx @@ -1,6 +1,6 @@ --- title: sdk/provider/Blockfrost.ts -nav_order: 145 +nav_order: 149 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/provider/Koios.mdx b/docs/content/docs/modules/sdk/provider/Koios.mdx index a0c7949c..ce5fdff5 100644 --- a/docs/content/docs/modules/sdk/provider/Koios.mdx +++ b/docs/content/docs/modules/sdk/provider/Koios.mdx @@ -1,6 +1,6 @@ --- title: sdk/provider/Koios.ts -nav_order: 146 +nav_order: 150 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/provider/Kupmios.mdx b/docs/content/docs/modules/sdk/provider/Kupmios.mdx index 24d79283..488579f4 100644 --- a/docs/content/docs/modules/sdk/provider/Kupmios.mdx +++ b/docs/content/docs/modules/sdk/provider/Kupmios.mdx @@ -1,6 +1,6 @@ --- title: sdk/provider/Kupmios.ts -nav_order: 147 +nav_order: 151 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/provider/Maestro.mdx b/docs/content/docs/modules/sdk/provider/Maestro.mdx index 16df126c..46e1e94f 100644 --- a/docs/content/docs/modules/sdk/provider/Maestro.mdx +++ b/docs/content/docs/modules/sdk/provider/Maestro.mdx @@ -1,6 +1,6 @@ --- title: sdk/provider/Maestro.ts -nav_order: 148 +nav_order: 152 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/provider/Provider.mdx b/docs/content/docs/modules/sdk/provider/Provider.mdx index ce42f041..412d5a67 100644 --- a/docs/content/docs/modules/sdk/provider/Provider.mdx +++ b/docs/content/docs/modules/sdk/provider/Provider.mdx @@ -1,6 +1,6 @@ --- title: sdk/provider/Provider.ts -nav_order: 149 +nav_order: 153 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/wallet/Derivation.mdx b/docs/content/docs/modules/sdk/wallet/Derivation.mdx index 4f77e7ff..24376a7f 100644 --- a/docs/content/docs/modules/sdk/wallet/Derivation.mdx +++ b/docs/content/docs/modules/sdk/wallet/Derivation.mdx @@ -1,6 +1,6 @@ --- title: sdk/wallet/Derivation.ts -nav_order: 155 +nav_order: 159 parent: Modules --- @@ -38,6 +38,8 @@ Result of deriving keys and addresses from a seed or Bip32 root - address: bech32 payment address (addr... / addr_test...) - rewardAddress: bech32 reward address (stake... / stake_test...) - paymentKey / stakeKey: ed25519e_sk bech32 private keys +- keyStore: Map of KeyHash hex -> PrivateKey for signing operations +- paymentKhHex / stakeKhHex: KeyHash hex strings for quick lookup **Signature** @@ -47,6 +49,9 @@ export type SeedDerivationResult = { rewardAddress: SdkRewardAddress.RewardAddress | undefined paymentKey: string stakeKey: string | undefined + keyStore: Map + paymentKhHex: string + stakeKhHex: string | undefined } ``` @@ -116,7 +121,7 @@ export declare function walletFromPrivateKey( addressType?: "Base" | "Enterprise" network?: "Mainnet" | "Testnet" | "Custom" } = {} -): SeedDerivationResult +): Effect.Effect ``` ## walletFromSeed @@ -132,8 +137,5 @@ export declare const walletFromSeed: ( accountIndex?: number network?: "Mainnet" | "Testnet" | "Custom" } -) => Either.Either< - { address: string; rewardAddress: string | undefined; paymentKey: string; stakeKey: string | undefined }, - Bip32PrivateKey.Bip32PrivateKeyError | AddressEras.AddressError | DerivationError -> +) => Effect.Effect ``` diff --git a/docs/content/docs/modules/sdk/wallet/Wallet.mdx b/docs/content/docs/modules/sdk/wallet/Wallet.mdx index 75a303e1..a514bd77 100644 --- a/docs/content/docs/modules/sdk/wallet/Wallet.mdx +++ b/docs/content/docs/modules/sdk/wallet/Wallet.mdx @@ -1,6 +1,6 @@ --- title: sdk/wallet/Wallet.ts -nav_order: 156 +nav_order: 160 parent: Modules --- diff --git a/docs/content/docs/modules/sdk/wallet/WalletNew.mdx b/docs/content/docs/modules/sdk/wallet/WalletNew.mdx index cba9d0b9..b6a8e719 100644 --- a/docs/content/docs/modules/sdk/wallet/WalletNew.mdx +++ b/docs/content/docs/modules/sdk/wallet/WalletNew.mdx @@ -1,6 +1,6 @@ --- title: sdk/wallet/WalletNew.ts -nav_order: 157 +nav_order: 161 parent: Modules --- @@ -116,8 +116,8 @@ Suitable for read-only applications that need wallet information. ```ts export interface ReadOnlyWalletEffect { - readonly address: Effect.Effect - readonly rewardAddress: Effect.Effect + readonly address: () => Effect.Effect + readonly rewardAddress: () => Effect.Effect } ``` diff --git a/docs/content/docs/modules/utils/FeeValidation.mdx b/docs/content/docs/modules/utils/FeeValidation.mdx index d48a6fef..6c025345 100644 --- a/docs/content/docs/modules/utils/FeeValidation.mdx +++ b/docs/content/docs/modules/utils/FeeValidation.mdx @@ -1,6 +1,6 @@ --- title: utils/FeeValidation.ts -nav_order: 158 +nav_order: 163 parent: Modules --- diff --git a/docs/content/docs/modules/utils/Hash.mdx b/docs/content/docs/modules/utils/Hash.mdx index da602dbc..68e4ee85 100644 --- a/docs/content/docs/modules/utils/Hash.mdx +++ b/docs/content/docs/modules/utils/Hash.mdx @@ -1,6 +1,6 @@ --- title: utils/Hash.ts -nav_order: 159 +nav_order: 164 parent: Modules --- diff --git a/docs/content/docs/modules/utils/effect-runtime.mdx b/docs/content/docs/modules/utils/effect-runtime.mdx new file mode 100644 index 00000000..b971673e --- /dev/null +++ b/docs/content/docs/modules/utils/effect-runtime.mdx @@ -0,0 +1,54 @@ +--- +title: utils/effect-runtime.ts +nav_order: 162 +parent: Modules +--- + +## effect-runtime overview + +--- + +

Table of contents

+ +- [utilities](#utilities) + - [runEffect](#runeffect) + +--- + +# utilities + +## runEffect + +Run an Effect and convert it to a Promise with clean error handling. + +- Executes the Effect using Effect.runPromiseExit +- On failure, extracts the error from the Exit and cleans stack traces +- Removes Effect.ts internal stack frames for cleaner error messages +- Throws the cleaned error for standard Promise error handling + +**Signature** + +```ts +export async function runEffect(effect: Effect.Effect): Promise +``` + +**Example** + +```typescript +import { Effect } from "effect" +import { runEffect } from "@evolution-sdk/evolution/utils/effect-runtime" + +const myEffect = Effect.succeed(42) + +async function example() { + try { + const result = await runEffect(myEffect) + console.log(result) + } catch (error) { + // Error with clean stack trace, no Effect.ts internals + console.error(error) + } +} +``` + +Added in v2.0.0 diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts index 830fb594..d39ca300 100644 --- a/docs/next-env.d.ts +++ b/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/evolution/docs/modules/core/Function.ts.md b/packages/evolution/docs/modules/core/Function.ts.md index b2c6ec6b..5cdf002b 100644 --- a/packages/evolution/docs/modules/core/Function.ts.md +++ b/packages/evolution/docs/modules/core/Function.ts.md @@ -121,7 +121,7 @@ export declare const makeCBOREncodeEither: , ErrorClass: ErrorCtor, defaultOptions?: CBOR.CodecOptions -) => (input: A, options?: CBOR.CodecOptions) => Either.Either +) => (value: A, options?: CBOR.CodecOptions) => Either.Either ``` ## makeCBOREncodeHexEither diff --git a/packages/evolution/docs/modules/core/Value.ts.md b/packages/evolution/docs/modules/core/Value.ts.md index 1b9c9d90..df8983e9 100644 --- a/packages/evolution/docs/modules/core/Value.ts.md +++ b/packages/evolution/docs/modules/core/Value.ts.md @@ -26,6 +26,8 @@ parent: Modules - [arbitrary](#arbitrary) - [model](#model) - [ValueCDDL (type alias)](#valuecddl-type-alias) +- [ordering](#ordering) + - [geq](#geq) - [parsing](#parsing) - [fromCBORBytes](#fromcborbytes) - [fromCBORHex](#fromcborhex) @@ -168,6 +170,21 @@ export type ValueCDDL = typeof FromCDDL.Type Added in v2.0.0 +# ordering + +## geq + +Check if Value a is greater than or equal to Value b. +This means after subtracting b from a, the result would not be negative. + +**Signature** + +```ts +export declare const geq: (a: Value, b: Value) => boolean +``` + +Added in v2.0.0 + # parsing ## fromCBORBytes diff --git a/packages/evolution/docs/modules/sdk/Credential.ts.md b/packages/evolution/docs/modules/sdk/Credential.ts.md index 7bd8d547..7d8afaab 100644 --- a/packages/evolution/docs/modules/sdk/Credential.ts.md +++ b/packages/evolution/docs/modules/sdk/Credential.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Credential.ts -nav_order: 135 +nav_order: 139 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Datum.ts.md b/packages/evolution/docs/modules/sdk/Datum.ts.md index 6834734b..dc4690bf 100644 --- a/packages/evolution/docs/modules/sdk/Datum.ts.md +++ b/packages/evolution/docs/modules/sdk/Datum.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Datum.ts -nav_order: 136 +nav_order: 140 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Delegation.ts.md b/packages/evolution/docs/modules/sdk/Delegation.ts.md index 206f7e1c..454a9aaa 100644 --- a/packages/evolution/docs/modules/sdk/Delegation.ts.md +++ b/packages/evolution/docs/modules/sdk/Delegation.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Delegation.ts -nav_order: 137 +nav_order: 141 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Devnet/Devnet.ts.md b/packages/evolution/docs/modules/sdk/Devnet/Devnet.ts.md index 80d4afe2..11db1761 100644 --- a/packages/evolution/docs/modules/sdk/Devnet/Devnet.ts.md +++ b/packages/evolution/docs/modules/sdk/Devnet/Devnet.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Devnet/Devnet.ts -nav_order: 138 +nav_order: 142 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Devnet/DevnetDefault.ts.md b/packages/evolution/docs/modules/sdk/Devnet/DevnetDefault.ts.md index 39e069a0..708e8b43 100644 --- a/packages/evolution/docs/modules/sdk/Devnet/DevnetDefault.ts.md +++ b/packages/evolution/docs/modules/sdk/Devnet/DevnetDefault.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Devnet/DevnetDefault.ts -nav_order: 139 +nav_order: 143 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/EvalRedeemer.ts.md b/packages/evolution/docs/modules/sdk/EvalRedeemer.ts.md index 2ee48238..ce46978f 100644 --- a/packages/evolution/docs/modules/sdk/EvalRedeemer.ts.md +++ b/packages/evolution/docs/modules/sdk/EvalRedeemer.ts.md @@ -1,6 +1,6 @@ --- title: sdk/EvalRedeemer.ts -nav_order: 140 +nav_order: 144 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Label.ts.md b/packages/evolution/docs/modules/sdk/Label.ts.md index 5bace55a..c5bd0832 100644 --- a/packages/evolution/docs/modules/sdk/Label.ts.md +++ b/packages/evolution/docs/modules/sdk/Label.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Label.ts -nav_order: 141 +nav_order: 145 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/OutRef.ts.md b/packages/evolution/docs/modules/sdk/OutRef.ts.md index 8e54aeff..7a767257 100644 --- a/packages/evolution/docs/modules/sdk/OutRef.ts.md +++ b/packages/evolution/docs/modules/sdk/OutRef.ts.md @@ -1,6 +1,6 @@ --- title: sdk/OutRef.ts -nav_order: 142 +nav_order: 146 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/PolicyId.ts.md b/packages/evolution/docs/modules/sdk/PolicyId.ts.md index ebc3c6ad..aa67bc2a 100644 --- a/packages/evolution/docs/modules/sdk/PolicyId.ts.md +++ b/packages/evolution/docs/modules/sdk/PolicyId.ts.md @@ -1,6 +1,6 @@ --- title: sdk/PolicyId.ts -nav_order: 143 +nav_order: 147 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/ProtocolParameters.ts.md b/packages/evolution/docs/modules/sdk/ProtocolParameters.ts.md index f2c68665..56bce525 100644 --- a/packages/evolution/docs/modules/sdk/ProtocolParameters.ts.md +++ b/packages/evolution/docs/modules/sdk/ProtocolParameters.ts.md @@ -1,6 +1,6 @@ --- title: sdk/ProtocolParameters.ts -nav_order: 144 +nav_order: 148 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/RewardAddress.ts.md b/packages/evolution/docs/modules/sdk/RewardAddress.ts.md index bf6d6fa9..8265dc9f 100644 --- a/packages/evolution/docs/modules/sdk/RewardAddress.ts.md +++ b/packages/evolution/docs/modules/sdk/RewardAddress.ts.md @@ -1,6 +1,6 @@ --- title: sdk/RewardAddress.ts -nav_order: 150 +nav_order: 154 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Script.ts.md b/packages/evolution/docs/modules/sdk/Script.ts.md index 1ab4b5b2..59845e04 100644 --- a/packages/evolution/docs/modules/sdk/Script.ts.md +++ b/packages/evolution/docs/modules/sdk/Script.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Script.ts -nav_order: 151 +nav_order: 155 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Type.ts.md b/packages/evolution/docs/modules/sdk/Type.ts.md index 842627f3..33d0f9b0 100644 --- a/packages/evolution/docs/modules/sdk/Type.ts.md +++ b/packages/evolution/docs/modules/sdk/Type.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Type.ts -nav_order: 152 +nav_order: 156 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/UTxO.ts.md b/packages/evolution/docs/modules/sdk/UTxO.ts.md index 815bee85..29aad4a7 100644 --- a/packages/evolution/docs/modules/sdk/UTxO.ts.md +++ b/packages/evolution/docs/modules/sdk/UTxO.ts.md @@ -1,6 +1,6 @@ --- title: sdk/UTxO.ts -nav_order: 154 +nav_order: 158 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/Unit.ts.md b/packages/evolution/docs/modules/sdk/Unit.ts.md index 37c0c589..308ed336 100644 --- a/packages/evolution/docs/modules/sdk/Unit.ts.md +++ b/packages/evolution/docs/modules/sdk/Unit.ts.md @@ -1,6 +1,6 @@ --- title: sdk/Unit.ts -nav_order: 153 +nav_order: 157 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/builders/SignBuilder.ts.md b/packages/evolution/docs/modules/sdk/builders/SignBuilder.ts.md index e4fd94d8..6d394fb2 100644 --- a/packages/evolution/docs/modules/sdk/builders/SignBuilder.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/SignBuilder.ts.md @@ -10,76 +10,57 @@ parent: Modules

Table of contents

-- [utils](#utils) +- [interfaces](#interfaces) - [SignBuilder (interface)](#signbuilder-interface) - [SignBuilderEffect (interface)](#signbuildereffect-interface) - - [SubmitBuilder (interface)](#submitbuilder-interface) - - [SubmitBuilderEffect (interface)](#submitbuildereffect-interface) --- -# utils +# interfaces ## SignBuilder (interface) +SignBuilder extends TransactionResultBase with signing capabilities. + +Only available when the client has a signing wallet (seed, private key, or API wallet). +Provides access to unsigned transaction (via base interface) and signing operations. + **Signature** ```ts -export interface SignBuilder extends EffectToPromiseAPI { +export interface SignBuilder extends TransactionResultBase, EffectToPromiseAPI { readonly Effect: SignBuilderEffect } ``` +Added in v2.0.0 + ## SignBuilderEffect (interface) +Effect-based API for SignBuilder operations. + +Includes all TransactionResultBase.Effect methods plus signing-specific operations. + **Signature** ```ts export interface SignBuilderEffect { - // Main signing method - produces a fully signed transaction ready for submission - readonly sign: () => Effect.Effect + // Base transaction methods (from TransactionResultBase) + readonly toTransaction: () => Effect.Effect + readonly toTransactionWithFakeWitnesses: () => Effect.Effect + readonly estimateFee: () => Effect.Effect - // Add external witness and proceed to submission + // Signing methods + readonly sign: () => Effect.Effect readonly signWithWitness: ( witnessSet: TransactionWitnessSet.TransactionWitnessSet ) => Effect.Effect - - // Assemble multiple witnesses into a complete transaction ready for submission readonly assemble: ( witnesses: ReadonlyArray ) => Effect.Effect - - // Partial signing - creates witness without advancing to submission (useful for multi-sig) readonly partialSign: () => Effect.Effect - - // Get witness set without signing (for inspection) readonly getWitnessSet: () => Effect.Effect - - // Get the unsigned transaction (for inspection) - readonly toTransaction: () => Effect.Effect - - // Get the transaction with fake witnesses (for fee validation) - readonly toTransactionWithFakeWitnesses: () => Effect.Effect -} -``` - -## SubmitBuilder (interface) - -**Signature** - -```ts -export interface SubmitBuilder extends EffectToPromiseAPI { - readonly Effect: SubmitBuilderEffect - readonly witnessSet: TransactionWitnessSet.TransactionWitnessSet } ``` -## SubmitBuilderEffect (interface) - -**Signature** - -```ts -export interface SubmitBuilderEffect { - readonly submit: () => Effect.Effect -} -``` +Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/SignBuilderImpl.ts.md b/packages/evolution/docs/modules/sdk/builders/SignBuilderImpl.ts.md new file mode 100644 index 00000000..40713c04 --- /dev/null +++ b/packages/evolution/docs/modules/sdk/builders/SignBuilderImpl.ts.md @@ -0,0 +1,51 @@ +--- +title: sdk/builders/SignBuilderImpl.ts +nav_order: 130 +parent: Modules +--- + +## SignBuilderImpl overview + +SignBuilder Implementation + +Handles transaction signing by delegating to the wallet's signTx Effect method. +The SignBuilder is responsible for: + +1. Providing the transaction and UTxO context to the wallet +2. Managing the transition from unsigned to signed transaction +3. Creating the SubmitBuilder for transaction submission + +The actual signing logic (determining required signers, creating witnesses) +is the wallet's responsibility. + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [makeSignBuilder](#makesignbuilder) + +--- + +# constructors + +## makeSignBuilder + +Create a SignBuilder instance for a built transaction. + +**Signature** + +```ts +export declare const makeSignBuilder: (params: { + transaction: Transaction.Transaction + transactionWithFakeWitnesses: Transaction.Transaction + fee: bigint + utxos: ReadonlyArray + provider: Provider.Provider + wallet: Wallet +}) => SignBuilder +``` + +Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/SubmitBuilder.ts.md b/packages/evolution/docs/modules/sdk/builders/SubmitBuilder.ts.md new file mode 100644 index 00000000..0a1c19a9 --- /dev/null +++ b/packages/evolution/docs/modules/sdk/builders/SubmitBuilder.ts.md @@ -0,0 +1,79 @@ +--- +title: sdk/builders/SubmitBuilder.ts +nav_order: 131 +parent: Modules +--- + +## SubmitBuilder overview + +SubmitBuilder - Final stage of transaction lifecycle + +Represents a signed transaction ready for submission to the blockchain. +Provides the submit() method to broadcast the transaction and retrieve the transaction hash. + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [interfaces](#interfaces) + - [SubmitBuilder (interface)](#submitbuilder-interface) + - [SubmitBuilderEffect (interface)](#submitbuildereffect-interface) + +--- + +# interfaces + +## SubmitBuilder (interface) + +SubmitBuilder - represents a signed transaction ready for submission. + +The final stage in the transaction lifecycle after building and signing. +Provides the submit() method to broadcast the transaction to the blockchain +and retrieve the transaction hash. + +**Signature** + +```ts +export interface SubmitBuilder extends EffectToPromiseAPI { + /** + * Effect-based API for compositional workflows. + * + * @since 2.0.0 + */ + readonly Effect: SubmitBuilderEffect + + /** + * The witness set containing all signatures for this transaction. + * + * Can be used to inspect the signatures or combine with other witness sets + * for multi-party signing scenarios. + * + * @since 2.0.0 + */ + readonly witnessSet: TransactionWitnessSet.TransactionWitnessSet +} +``` + +Added in v2.0.0 + +## SubmitBuilderEffect (interface) + +Effect-based API for SubmitBuilder operations. + +**Signature** + +```ts +export interface SubmitBuilderEffect { + /** + * Submit the signed transaction to the blockchain via the provider. + * + * @returns Effect resolving to the transaction hash + * @since 2.0.0 + */ + readonly submit: () => Effect.Effect +} +``` + +Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/SubmitBuilderImpl.ts.md b/packages/evolution/docs/modules/sdk/builders/SubmitBuilderImpl.ts.md new file mode 100644 index 00000000..db44c1a9 --- /dev/null +++ b/packages/evolution/docs/modules/sdk/builders/SubmitBuilderImpl.ts.md @@ -0,0 +1,45 @@ +--- +title: sdk/builders/SubmitBuilderImpl.ts +nav_order: 132 +parent: Modules +--- + +## SubmitBuilderImpl overview + +SubmitBuilder Implementation + +Handles transaction submission by delegating to the provider's submitTx method. +The SubmitBuilder is responsible for: + +1. Converting the signed transaction to CBOR hex format +2. Submitting to the provider's Effect.submitTx +3. Returning the transaction hash + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [makeSubmitBuilder](#makesubmitbuilder) + +--- + +# constructors + +## makeSubmitBuilder + +Create a SubmitBuilder instance for a signed transaction. + +**Signature** + +```ts +export declare const makeSubmitBuilder: ( + signedTransaction: Transaction.Transaction, + witnessSet: TransactionWitnessSet.TransactionWitnessSet, + provider: Provider.Provider +) => SubmitBuilder +``` + +Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md b/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md index 1965a82d..dabd63c0 100644 --- a/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/TransactionBuilder.ts.md @@ -1,6 +1,6 @@ --- title: sdk/builders/TransactionBuilder.ts -nav_order: 130 +nav_order: 133 parent: Modules --- @@ -38,6 +38,9 @@ double-spending. UTxOs can come from any source (wallet, DeFi protocols, other p - [constructors](#constructors) - [makeTxBuilder](#maketxbuilder) - [context](#context) + - [AvailableUtxosTag (class)](#availableutxostag-class) + - [ChangeAddressTag (class)](#changeaddresstag-class) + - [ProtocolParametersTag (class)](#protocolparameterstag-class) - [TxContext (class)](#txcontext-class) - [TxContextData (interface)](#txcontextdata-interface) - [errors](#errors) @@ -107,34 +110,49 @@ Added in v2.0.0 Configuration for TransactionBuilder. Immutable configuration passed to builder at creation time. -Contains: +Wallet-centric design (when wallet provided): -- Protocol parameters for fee calculation -- Change address for leftover funds -- Available UTxOs for coin selection +- Wallet provides change address (via wallet.Effect.address()) +- Provider + Wallet provide available UTxOs (via provider.Effect.getUtxos(wallet.address)) +- Override per-build via BuildOptions if needed + +Manual mode (no wallet): + +- Must provide changeAddress and availableUtxos in BuildOptions for each build +- Used for read-only scenarios or advanced use cases **Signature** ```ts export interface TxBuilderConfig { - readonly protocolParameters: ProtocolParameters - /** - * Address to send change (leftover assets) to. - * This is required for proper transaction balancing. + * Optional wallet provides: + * - Change address via wallet.Effect.address() + * - Available UTxOs via wallet.Effect.address() + provider.Effect.getUtxos() + * - Signing capability via wallet.Effect.signTx() (SigningWallet and ApiWallet only) + * + * When provided: Automatic change address and UTxO resolution. + * When omitted: Must provide changeAddress and availableUtxos in BuildOptions. + * + * ReadOnlyWallet: For read-only clients that can build but not sign transactions. + * SigningWallet/ApiWallet: For signing clients with full transaction signing capability. + * + * Override per-build via BuildOptions.changeAddress and BuildOptions.availableUtxos. */ - readonly changeAddress: string + readonly wallet?: WalletNew.SigningWallet | WalletNew.ApiWallet | WalletNew.ReadOnlyWallet /** - * UTxOs available for coin selection. - * These can be from a wallet, another user, or any other source. - * Coin selection will automatically select from these UTxOs to cover - * required outputs + fees, excluding any already collected via collectFrom(). + * Optional provider for: + * - Fetching UTxOs for the wallet's address (provider.Effect.getUtxos) + * - Transaction submission (provider.Effect.submitTx) + * - Protocol parameters + * + * Works together with wallet to provide everything needed for transaction building. + * When wallet is omitted, provider is only used if you call provider methods directly. */ - readonly availableUtxos: ReadonlyArray + readonly provider?: Provider.Provider // Future fields: - // readonly provider?: any // Provider interface for blockchain communication // readonly costModels?: Uint8Array // Cost models for script evaluation } ``` @@ -151,16 +169,75 @@ The builder accumulates chainable method calls as deferred ProgramSteps. Calling creates fresh state (new Refs) and executes all accumulated programs sequentially, ensuring no state pollution between invocations. +Generic type parameter TResult determines what build() returns: + +- SignBuilder (default): When wallet has signing capability +- TransactionResultBase: When wallet is read-only + **Signature** ```ts -export declare const makeTxBuilder: (config: TxBuilderConfig) => TransactionBuilder +export declare const makeTxBuilder: (config: TxBuilderConfig) => TransactionBuilder ``` Added in v2.0.0 # context +## AvailableUtxosTag (class) + +Resolved available UTxOs for the current build. +This is resolved once at the start of build() from either: + +- BuildOptions.availableUtxos (per-transaction override) +- provider.Effect.getUtxos(wallet.address) (default from wallet + provider) + +Available to all phase functions via Effect Context. + +**Signature** + +```ts +export declare class AvailableUtxosTag +``` + +Added in v2.0.0 + +## ChangeAddressTag (class) + +Resolved change address for the current build. +This is resolved once at the start of build() from either: + +- BuildOptions.changeAddress (per-transaction override) +- TxBuilderConfig.wallet.Effect.address() (default from wallet) + +Available to all phase functions via Effect Context. + +**Signature** + +```ts +export declare class ChangeAddressTag +``` + +Added in v2.0.0 + +## ProtocolParametersTag (class) + +Resolved protocol parameters for the current build. +This is resolved once at the start of build() from either: + +- BuildOptions.protocolParameters (per-transaction override) +- provider.Effect.getProtocolParameters() (fetched from provider) + +Available to all phase functions via Effect Context. + +**Signature** + +```ts +export declare class ProtocolParametersTag +``` + +Added in v2.0.0 + ## TxContext (class) Single Context service providing all transaction building data to programs. @@ -248,6 +325,12 @@ Key Design Principle: Builder instance never mutates. Programs are deferred Effects that execute later. Each build() creates fresh TxBuilderState, executes programs, returns result. +Generic Type Parameter: +TResult determines the return type of build() methods: + +- SignBuilder: When wallet has signing capability (SigningClient) +- TransactionResultBase: When wallet is read-only (ReadOnlyClient) + Usage Pattern: ```typescript @@ -265,7 +348,7 @@ const signBuilder2 = await builder.build() **Signature** ```ts -export interface TransactionBuilder { +export interface TransactionBuilder { // ============================================================================ // Chainable Builder Methods - Create ProgramSteps, return same builder // ============================================================================ @@ -279,7 +362,7 @@ export interface TransactionBuilder { * @since 2.0.0 * @category builder-methods */ - readonly payToAddress: (params: PayToAddressParams) => TransactionBuilder + readonly payToAddress: (params: PayToAddressParams) => TransactionBuilder /** * Specify transaction inputs from provided UTxOs. @@ -290,7 +373,7 @@ export interface TransactionBuilder { * @since 2.0.0 * @category builder-methods */ - readonly collectFrom: (params: CollectFromParams) => TransactionBuilder + readonly collectFrom: (params: CollectFromParams) => TransactionBuilder // Future expansion points for other operations: // readonly mintTokens: (params: MintTokensParams) => TransactionBuilder @@ -309,10 +392,14 @@ export interface TransactionBuilder { * Creates fresh state and runs all accumulated ProgramSteps sequentially. * Can be called multiple times on the same builder instance with independent results. * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) + * * @since 2.0.0 * @category completion-methods */ - readonly build: (options?: BuildOptions) => Promise + readonly build: (options?: BuildOptions) => Promise /** * Execute all queued operations and return a signing-ready transaction via Effect. @@ -320,25 +407,43 @@ export interface TransactionBuilder { * Creates fresh state and runs all accumulated ProgramSteps sequentially. * Suitable for Effect-TS compositional workflows and error handling. * + * Error types include WalletError and ProviderError from config Effects. + * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) + * * @since 2.0.0 * @category completion-methods */ readonly buildEffect: ( options?: BuildOptions - ) => Effect.Effect + ) => Effect.Effect< + TResult, + TransactionBuilderError | EvaluationError | WalletNew.WalletError | Provider.ProviderError, + unknown + > /** * Execute all queued operations with explicit error handling via Either. * * Creates fresh state and runs all accumulated ProgramSteps sequentially. - * Returns Either for pattern-matched error recovery. + * Returns Either for pattern-matched error recovery. + * + * Error types include WalletError and ProviderError from config Effects. + * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) * * @since 2.0.0 * @category completion-methods */ readonly buildEither: ( options?: BuildOptions - ) => Promise> + ) => Promise< + Either + > // ============================================================================ // Transaction Chaining Methods - Multi-transaction workflows @@ -600,6 +705,29 @@ Added in v2.0.0 ````ts export interface BuildOptions { + /** + * Override protocol parameters for this specific transaction build. + * + * By default, fetches from provider during build(). + * Provide this to use different protocol parameters for testing or special cases. + * + * Use cases: + * - Testing with different fee parameters + * - Simulating future protocol changes + * - Using cached parameters to avoid provider fetch + * + * Example: + * ```typescript + * // Test with custom fee parameters + * builder.build({ + * protocolParameters: { ...params, minFeeCoefficient: 50n, minFeeConstant: 200000n } + * }) + * ``` + * + * @since 2.0.0 + */ + readonly protocolParameters?: ProtocolParameters + /** * Coin selection strategy for automatic input selection. * @@ -624,6 +752,56 @@ export interface BuildOptions { // Change Handling Configuration // ============================================================================ + /** + * Override the change address for this specific transaction build. + * + * By default, uses wallet.Effect.address() from TxBuilderConfig. + * Provide this to use a different address for change outputs. + * + * Use cases: + * - Multi-address wallet (use account index 5 for change) + * - Different change address per transaction + * - Multi-sig workflows where change address varies + * - Testing with different addresses + * + * Example: + * ```typescript + * // Use different account for change + * builder.build({ changeAddress: wallet.addresses[5] }) + * + * // Custom address + * builder.build({ changeAddress: "addr_test1..." }) + * ``` + * + * @since 2.0.0 + */ + readonly changeAddress?: string + + /** + * Override the available UTxOs for this specific transaction build. + * + * By default, fetches UTxOs from provider.Effect.getUtxos(wallet.address). + * Provide this to use a specific set of UTxOs for coin selection. + * + * Use cases: + * - Use UTxOs from specific account index + * - Pre-filtered UTxO set + * - Testing with known UTxO set + * - Multi-address UTxO aggregation + * + * Example: + * ```typescript + * // Use UTxOs from specific account + * builder.build({ availableUtxos: utxosFromAccount5 }) + * + * // Combine UTxOs from multiple addresses + * builder.build({ availableUtxos: [...utxos1, ...utxos2] }) + * ``` + * + * @since 2.0.0 + */ + readonly availableUtxos?: ReadonlyArray + /** * # Change Handling Strategy Matrix * @@ -739,12 +917,11 @@ export interface BuildOptions { readonly useStateMachine?: boolean /** - * **EXPERIMENTAL**: Use V3 4-phase state machine * - * When true, uses V3's simplified 4-phase state machine: + * When true, uses simplified 4-phase state machine: * - selection → changeValidation → balanceVerification → fallback → complete * - * V3 shares TxContext with V2 but uses mathematical validation approach. + * shares TxContext with V2 but uses mathematical validation approach. * * @experimental * @default false diff --git a/packages/evolution/docs/modules/sdk/builders/TransactionResult.ts.md b/packages/evolution/docs/modules/sdk/builders/TransactionResult.ts.md new file mode 100644 index 00000000..a7a5222e --- /dev/null +++ b/packages/evolution/docs/modules/sdk/builders/TransactionResult.ts.md @@ -0,0 +1,160 @@ +--- +title: sdk/builders/TransactionResult.ts +nav_order: 134 +parent: Modules +--- + +## TransactionResult overview + +TransactionResult - Base interface for transaction building results + +Provides core functionality available to all transaction builders regardless +of signing capability. This enables type-safe differentiation between +read-only clients (can build but not sign) and signing clients (can build and sign). + +Added in v2.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [makeTransactionResult](#maketransactionresult) +- [interfaces](#interfaces) + - [TransactionResultBase (interface)](#transactionresultbase-interface) + +--- + +# constructors + +## makeTransactionResult + +Create a TransactionResultBase instance for a built transaction without signing capability. + +Used by ReadOnlyClient which can build transactions but cannot sign them. +Provides access to the unsigned transaction, fake-witness transaction for fee validation, +and fee estimation. + +**Signature** + +```ts +export declare const makeTransactionResult: (params: { + transaction: Transaction.Transaction + transactionWithFakeWitnesses: Transaction.Transaction + fee: bigint +}) => TransactionResultBase +``` + +Added in v2.0.0 + +# interfaces + +## TransactionResultBase (interface) + +Base result interface for built transactions. + +Available on all transaction builders regardless of signing capability. +Provides access to the unsigned transaction, fee estimates, and transaction +with fake witnesses for size validation. + +**Signature** + +````ts +export interface TransactionResultBase { + /** + * Get the unsigned transaction. + * + * This transaction has a complete body but no witness set (signatures). + * Can be serialized to CBOR for external signing (hardware wallets, browser extensions, etc.) + * + * @returns Promise resolving to the unsigned transaction + * + * @example + * ```typescript + * const result = await readOnlyClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5_000_000n }) + * .build() + * + * const unsignedTx = await result.toTransaction() + * const txCbor = Transaction.toCBORHex(unsignedTx) + * // Export for external signing + * ``` + * + * @since 2.0.0 + * @category accessors + */ + readonly toTransaction: () => Promise + + /** + * Get the transaction with fake witnesses for fee validation. + * + * This transaction includes fake witness sets (294 bytes each) to accurately + * calculate the final transaction size and fees. Useful for validating that + * the calculated fee is sufficient for the final signed transaction. + * + * @returns Promise resolving to the transaction with fake witnesses + * + * @since 2.0.0 + * @category accessors + */ + readonly toTransactionWithFakeWitnesses: () => Promise + + /** + * Get the calculated transaction fee in lovelace. + * + * This is the fee that was calculated during the build process based on + * the transaction size (including fake witnesses) and protocol parameters. + * + * @returns Promise resolving to the transaction fee in lovelace + * + * @example + * ```typescript + * const result = await client.newTx() + * .payToAddress({ address: "addr...", lovelace: 5_000_000n }) + * .build() + * + * const fee = await result.estimateFee() + * console.log(`Transaction fee: ${fee} lovelace`) + * ``` + * + * @since 2.0.0 + * @category accessors + */ + readonly estimateFee: () => Promise + + /** + * Effect-based API for compositional workflows. + * + * Provides the same functionality as the Promise-based methods but returns + * Effect values for use in Effect-TS workflows with proper error handling + * and composition. + * + * @since 2.0.0 + * @category effects + */ + readonly Effect: { + /** + * Get the unsigned transaction as an Effect. + * + * @since 2.0.0 + */ + readonly toTransaction: () => Effect.Effect + + /** + * Get the transaction with fake witnesses as an Effect. + * + * @since 2.0.0 + */ + readonly toTransactionWithFakeWitnesses: () => Effect.Effect + + /** + * Get the calculated fee as an Effect. + * + * @since 2.0.0 + */ + readonly estimateFee: () => Effect.Effect + } +} +```` + +Added in v2.0.0 diff --git a/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md b/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md index 17730956..86a63091 100644 --- a/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/TxBuilderImpl.ts.md @@ -1,6 +1,6 @@ --- title: sdk/builders/TxBuilderImpl.ts -nav_order: 131 +nav_order: 135 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/builders/Unfrack.ts.md b/packages/evolution/docs/modules/sdk/builders/Unfrack.ts.md index 27f014a2..63e8ea78 100644 --- a/packages/evolution/docs/modules/sdk/builders/Unfrack.ts.md +++ b/packages/evolution/docs/modules/sdk/builders/Unfrack.ts.md @@ -1,6 +1,6 @@ --- title: sdk/builders/Unfrack.ts -nav_order: 132 +nav_order: 136 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/client/Client.ts.md b/packages/evolution/docs/modules/sdk/client/Client.ts.md index 07c0f26a..eb64a85c 100644 --- a/packages/evolution/docs/modules/sdk/client/Client.ts.md +++ b/packages/evolution/docs/modules/sdk/client/Client.ts.md @@ -1,6 +1,6 @@ --- title: sdk/client/Client.ts -nav_order: 133 +nav_order: 137 parent: Modules --- @@ -24,6 +24,7 @@ parent: Modules - [MinimalClient (interface)](#minimalclient-interface) - [MinimalClientEffect (interface)](#minimalclienteffect-interface) - [NetworkId (type alias)](#networkid-type-alias) + - [PrivateKeyWalletConfig (interface)](#privatekeywalletconfig-interface) - [ProviderConfig (type alias)](#providerconfig-type-alias) - [ProviderOnlyClient (type alias)](#provideronlyclient-type-alias) - [ReadOnlyClient (type alias)](#readonlyclient-type-alias) @@ -156,13 +157,21 @@ export interface MinimalClient { config: T ) => T extends SeedWalletConfig ? SigningWalletClient - : T extends ApiWalletConfig - ? ApiWalletClient - : ReadOnlyWalletClient + : T extends PrivateKeyWalletConfig + ? SigningWalletClient + : T extends ApiWalletConfig + ? ApiWalletClient + : ReadOnlyWalletClient readonly attach: ( providerConfig: ProviderConfig, walletConfig: TW - ) => TW extends SeedWalletConfig ? SigningClient : TW extends ApiWalletConfig ? SigningClient : ReadOnlyClient + ) => TW extends SeedWalletConfig + ? SigningClient + : TW extends PrivateKeyWalletConfig + ? SigningClient + : TW extends ApiWalletConfig + ? SigningClient + : ReadOnlyClient // Effect namespace for methods with side effects only readonly Effect: MinimalClientEffect } @@ -188,6 +197,19 @@ export interface MinimalClientEffect { export type NetworkId = "mainnet" | "preprod" | "preview" | number ``` +## PrivateKeyWalletConfig (interface) + +**Signature** + +```ts +export interface PrivateKeyWalletConfig { + readonly type: "private-key" + readonly paymentKey: string // bech32 ed25519e_sk + readonly stakeKey?: string // bech32 ed25519e_sk (optional, for Base addresses) + readonly addressType?: "Base" | "Enterprise" +} +``` + ## ProviderConfig (type alias) **Signature** @@ -207,7 +229,13 @@ export type ProviderOnlyClient = EffectToPromiseAPI & { // Combinator methods (pure, no side effects) with type-aware conditional return type readonly attachWallet: ( config: T - ) => T extends SeedWalletConfig ? SigningClient : T extends ApiWalletConfig ? SigningClient : ReadOnlyClient + ) => T extends SeedWalletConfig + ? SigningClient + : T extends PrivateKeyWalletConfig + ? SigningClient + : T extends ApiWalletConfig + ? SigningClient + : ReadOnlyClient // Effect namespace - includes all provider methods as Effects readonly Effect: Provider.ProviderEffect } @@ -217,15 +245,48 @@ export type ProviderOnlyClient = EffectToPromiseAPI & { ReadOnlyClient - can query blockchain + wallet address operations +ReadOnlyClient cannot sign transactions, so newTx() returns a TransactionBuilder +that yields TransactionResultBase (unsigned transaction only). + **Signature** -```ts +````ts export type ReadOnlyClient = EffectToPromiseAPI & { - readonly newTx: (utxos?: ReadonlyArray) => any // TODO: Change to ReadOnlyTransactionBuilder when implementing tx builder + /** + * Create a new transaction builder for read-only operations. + * + * Returns a TransactionBuilder that builds unsigned transactions. + * The build() methods return TransactionResultBase which provides: + * - `.toTransaction()` - Get the unsigned transaction + * - `.toTransactionWithFakeWitnesses()` - Get transaction with fake witnesses for fee validation + * - `.estimateFee()` - Get the calculated fee + * + * @param utxos - Optional UTxOs to use for coin selection. If not provided, wallet UTxOs will be fetched automatically when build() is called. + * @returns A new TransactionBuilder instance configured with cached protocol parameters and wallet change address. + * + * @example + * ```typescript + * // Build unsigned transaction + * const result = await readOnlyClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5000000n }) + * .build() + * + * // Get unsigned transaction for external signing + * const unsignedTx = await result.toTransaction() + * const txCbor = Transaction.toCBORHex(unsignedTx) + * + * // Get fee estimate + * const fee = await result.estimateFee() + * ``` + * + * @since 2.0.0 + * @category transaction-building + */ + readonly newTx: (utxos?: ReadonlyArray) => TransactionBuilder // Effect namespace - includes all provider + wallet methods as Effects readonly Effect: ReadOnlyClientEffect } -``` +```` ## ReadOnlyClientEffect (interface) @@ -350,15 +411,53 @@ export interface SeedWalletConfig { SigningClient - full functionality: query blockchain + sign + submit +SigningClient has wallet signing capability, so newTx() returns a TransactionBuilder +that yields SignBuilder (can sign and submit transactions). + **Signature** -```ts +````ts export type SigningClient = EffectToPromiseAPI & { - readonly newTx: (utxos?: ReadonlyArray) => any // TODO: Change to ReadOnlyTransactionBuilder when implementing tx builder + /** + * Create a new transaction builder with signing capability. + * + * Returns a TransactionBuilder that can build and sign transactions. + * The build() methods return SignBuilder which provides: + * - `.sign()` - Sign and prepare for submission + * - `.toTransaction()` - Get the unsigned transaction + * - `.toTransactionWithFakeWitnesses()` - Get transaction with fake witnesses for fee validation + * - `.estimateFee()` - Get the calculated fee + * - `.partialSign()` - Create partial signature for multi-sig + * - `.assemble()` - Combine multiple signatures + * + * UTxOs for coin selection are fetched automatically from the wallet when build() is called. + * You can override UTxOs per-build using BuildOptions.availableUtxos. + * + * @returns A new TransactionBuilder instance configured with cached protocol parameters and wallet change address. + * + * @example + * ```typescript + * // Build and sign transaction + * const signBuilder = await signingClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5000000n }) + * .build() + * + * // Sign and submit + * const submitBuilder = await signBuilder.sign() + * const txHash = await submitBuilder.submit() + * + * // Or get unsigned transaction + * const unsignedTx = await signBuilder.toTransaction() + * ``` + * + * @since 2.0.0 + * @category transaction-building + */ + readonly newTx: () => TransactionBuilder // Effect namespace - includes all provider + wallet methods as Effects readonly Effect: SigningClientEffect } -``` +```` ## SigningClientEffect (interface) @@ -396,5 +495,5 @@ export type SigningWalletClient = EffectToPromiseAPI & { **Signature** ```ts -export type WalletConfig = SeedWalletConfig | ReadOnlyWalletConfig | ApiWalletConfig +export type WalletConfig = SeedWalletConfig | PrivateKeyWalletConfig | ReadOnlyWalletConfig | ApiWalletConfig ``` diff --git a/packages/evolution/docs/modules/sdk/client/ClientImpl.ts.md b/packages/evolution/docs/modules/sdk/client/ClientImpl.ts.md index 2ac94538..50fa7dea 100644 --- a/packages/evolution/docs/modules/sdk/client/ClientImpl.ts.md +++ b/packages/evolution/docs/modules/sdk/client/ClientImpl.ts.md @@ -1,6 +1,6 @@ --- title: sdk/client/ClientImpl.ts -nav_order: 134 +nav_order: 138 parent: Modules --- @@ -51,6 +51,10 @@ export declare function createClient(config: { wallet: ReadOnlyWalletConfig }): ReadOnlyWalletClient export declare function createClient(config: { network?: NetworkId; wallet: SeedWalletConfig }): SigningWalletClient +export declare function createClient(config: { + network?: NetworkId + wallet: PrivateKeyWalletConfig +}): SigningWalletClient export declare function createClient(config: { network?: NetworkId; wallet: ApiWalletConfig }): ApiWalletClient export declare function createClient(config?: { network?: NetworkId }): MinimalClient ``` diff --git a/packages/evolution/docs/modules/sdk/provider/Blockfrost.ts.md b/packages/evolution/docs/modules/sdk/provider/Blockfrost.ts.md index 3540ca02..4f3d5824 100644 --- a/packages/evolution/docs/modules/sdk/provider/Blockfrost.ts.md +++ b/packages/evolution/docs/modules/sdk/provider/Blockfrost.ts.md @@ -1,6 +1,6 @@ --- title: sdk/provider/Blockfrost.ts -nav_order: 145 +nav_order: 149 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/provider/Koios.ts.md b/packages/evolution/docs/modules/sdk/provider/Koios.ts.md index d25ddf3c..dfbc3499 100644 --- a/packages/evolution/docs/modules/sdk/provider/Koios.ts.md +++ b/packages/evolution/docs/modules/sdk/provider/Koios.ts.md @@ -1,6 +1,6 @@ --- title: sdk/provider/Koios.ts -nav_order: 146 +nav_order: 150 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/provider/Kupmios.ts.md b/packages/evolution/docs/modules/sdk/provider/Kupmios.ts.md index 1a0d0252..30a3506c 100644 --- a/packages/evolution/docs/modules/sdk/provider/Kupmios.ts.md +++ b/packages/evolution/docs/modules/sdk/provider/Kupmios.ts.md @@ -1,6 +1,6 @@ --- title: sdk/provider/Kupmios.ts -nav_order: 147 +nav_order: 151 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/provider/Maestro.ts.md b/packages/evolution/docs/modules/sdk/provider/Maestro.ts.md index 20d587d9..be63f6c5 100644 --- a/packages/evolution/docs/modules/sdk/provider/Maestro.ts.md +++ b/packages/evolution/docs/modules/sdk/provider/Maestro.ts.md @@ -1,6 +1,6 @@ --- title: sdk/provider/Maestro.ts -nav_order: 148 +nav_order: 152 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/provider/Provider.ts.md b/packages/evolution/docs/modules/sdk/provider/Provider.ts.md index db8888c2..5f20e785 100644 --- a/packages/evolution/docs/modules/sdk/provider/Provider.ts.md +++ b/packages/evolution/docs/modules/sdk/provider/Provider.ts.md @@ -1,6 +1,6 @@ --- title: sdk/provider/Provider.ts -nav_order: 149 +nav_order: 153 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/wallet/Derivation.ts.md b/packages/evolution/docs/modules/sdk/wallet/Derivation.ts.md index d385b78d..de2f7983 100644 --- a/packages/evolution/docs/modules/sdk/wallet/Derivation.ts.md +++ b/packages/evolution/docs/modules/sdk/wallet/Derivation.ts.md @@ -1,6 +1,6 @@ --- title: sdk/wallet/Derivation.ts -nav_order: 155 +nav_order: 159 parent: Modules --- @@ -38,6 +38,8 @@ Result of deriving keys and addresses from a seed or Bip32 root - address: bech32 payment address (addr... / addr_test...) - rewardAddress: bech32 reward address (stake... / stake_test...) - paymentKey / stakeKey: ed25519e_sk bech32 private keys +- keyStore: Map of KeyHash hex -> PrivateKey for signing operations +- paymentKhHex / stakeKhHex: KeyHash hex strings for quick lookup **Signature** @@ -47,6 +49,9 @@ export type SeedDerivationResult = { rewardAddress: SdkRewardAddress.RewardAddress | undefined paymentKey: string stakeKey: string | undefined + keyStore: Map + paymentKhHex: string + stakeKhHex: string | undefined } ``` @@ -116,7 +121,7 @@ export declare function walletFromPrivateKey( addressType?: "Base" | "Enterprise" network?: "Mainnet" | "Testnet" | "Custom" } = {} -): SeedDerivationResult +): Effect.Effect ``` ## walletFromSeed @@ -132,8 +137,5 @@ export declare const walletFromSeed: ( accountIndex?: number network?: "Mainnet" | "Testnet" | "Custom" } -) => Either.Either< - { address: string; rewardAddress: string | undefined; paymentKey: string; stakeKey: string | undefined }, - Bip32PrivateKey.Bip32PrivateKeyError | AddressEras.AddressError | DerivationError -> +) => Effect.Effect ``` diff --git a/packages/evolution/docs/modules/sdk/wallet/Wallet.ts.md b/packages/evolution/docs/modules/sdk/wallet/Wallet.ts.md index 6cb6ae3d..449b61de 100644 --- a/packages/evolution/docs/modules/sdk/wallet/Wallet.ts.md +++ b/packages/evolution/docs/modules/sdk/wallet/Wallet.ts.md @@ -1,6 +1,6 @@ --- title: sdk/wallet/Wallet.ts -nav_order: 156 +nav_order: 160 parent: Modules --- diff --git a/packages/evolution/docs/modules/sdk/wallet/WalletNew.ts.md b/packages/evolution/docs/modules/sdk/wallet/WalletNew.ts.md index b6970c89..1c276369 100644 --- a/packages/evolution/docs/modules/sdk/wallet/WalletNew.ts.md +++ b/packages/evolution/docs/modules/sdk/wallet/WalletNew.ts.md @@ -1,6 +1,6 @@ --- title: sdk/wallet/WalletNew.ts -nav_order: 157 +nav_order: 161 parent: Modules --- @@ -116,8 +116,8 @@ Suitable for read-only applications that need wallet information. ```ts export interface ReadOnlyWalletEffect { - readonly address: Effect.Effect - readonly rewardAddress: Effect.Effect + readonly address: () => Effect.Effect + readonly rewardAddress: () => Effect.Effect } ``` diff --git a/packages/evolution/docs/modules/utils/FeeValidation.ts.md b/packages/evolution/docs/modules/utils/FeeValidation.ts.md index 9f639545..0c75155b 100644 --- a/packages/evolution/docs/modules/utils/FeeValidation.ts.md +++ b/packages/evolution/docs/modules/utils/FeeValidation.ts.md @@ -1,6 +1,6 @@ --- title: utils/FeeValidation.ts -nav_order: 158 +nav_order: 163 parent: Modules --- diff --git a/packages/evolution/docs/modules/utils/Hash.ts.md b/packages/evolution/docs/modules/utils/Hash.ts.md index 422a33b7..fde83735 100644 --- a/packages/evolution/docs/modules/utils/Hash.ts.md +++ b/packages/evolution/docs/modules/utils/Hash.ts.md @@ -1,6 +1,6 @@ --- title: utils/Hash.ts -nav_order: 159 +nav_order: 164 parent: Modules --- diff --git a/packages/evolution/docs/modules/utils/effect-runtime.ts.md b/packages/evolution/docs/modules/utils/effect-runtime.ts.md new file mode 100644 index 00000000..e3766a63 --- /dev/null +++ b/packages/evolution/docs/modules/utils/effect-runtime.ts.md @@ -0,0 +1,54 @@ +--- +title: utils/effect-runtime.ts +nav_order: 162 +parent: Modules +--- + +## effect-runtime overview + +--- + +

Table of contents

+ +- [utilities](#utilities) + - [runEffect](#runeffect) + +--- + +# utilities + +## runEffect + +Run an Effect and convert it to a Promise with clean error handling. + +- Executes the Effect using Effect.runPromiseExit +- On failure, extracts the error from the Exit and cleans stack traces +- Removes Effect.ts internal stack frames for cleaner error messages +- Throws the cleaned error for standard Promise error handling + +**Signature** + +```ts +export async function runEffect(effect: Effect.Effect): Promise
+``` + +**Example** + +```typescript +import { Effect } from "effect" +import { runEffect } from "@evolution-sdk/evolution/utils/effect-runtime" + +const myEffect = Effect.succeed(42) + +async function example() { + try { + const result = await runEffect(myEffect) + console.log(result) + } catch (error) { + // Error with clean stack trace, no Effect.ts internals + console.error(error) + } +} +``` + +Added in v2.0.0 diff --git a/packages/evolution/package.json b/packages/evolution/package.json index e2a30f2f..9c0cdebb 100644 --- a/packages/evolution/package.json +++ b/packages/evolution/package.json @@ -39,7 +39,6 @@ "devDependencies": { "@dcspark/cardano-multiplatform-lib-nodejs": "^6.2.0", "@types/dockerode": "^3.3.43", - "@types/libsodium-wrappers-sumo": "^0.7.8", "tinybench": "^5.0.0", "tsx": "^4.20.4", "typescript": "^5.9.2" @@ -47,6 +46,7 @@ "dependencies": { "@effect/platform": "^0.90.6", "@effect/platform-node": "^0.96.0", + "@noble/curves": "^2.0.1", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", "@scure/bip32": "^1.7.0", @@ -54,8 +54,7 @@ "@types/bip39": "^3.0.4", "bip39": "^3.1.0", "dockerode": "^4.0.7", - "effect": "^3.17.9", - "libsodium-wrappers-sumo": "^0.7.15" + "effect": "^3.17.9" }, "keywords": [ "cardano", diff --git a/packages/evolution/src/core/Bip32PrivateKey.ts b/packages/evolution/src/core/Bip32PrivateKey.ts index e785c12e..efe69d1a 100644 --- a/packages/evolution/src/core/Bip32PrivateKey.ts +++ b/packages/evolution/src/core/Bip32PrivateKey.ts @@ -1,7 +1,10 @@ +import { mod } from "@noble/curves/abstract/modular.js" +import { ed25519 } from "@noble/curves/ed25519.js" +import { bytesToNumberLE } from "@noble/curves/utils.js" +import { hmac } from "@noble/hashes/hmac.js" import { pbkdf2 } from "@noble/hashes/pbkdf2" import { sha512 } from "@noble/hashes/sha2" -import { Data, Either as E, FastCheck, Schema } from "effect" -import sodium from "libsodium-wrappers-sumo" +import { Data, Effect, FastCheck, Schema } from "effect" import * as Bip32PublicKey from "./Bip32PublicKey.js" import * as Bytes from "./Bytes.js" @@ -9,9 +12,6 @@ import * as Bytes96 from "./Bytes96.js" import * as Function from "./Function.js" import * as PrivateKey from "./PrivateKey.js" -// Initialize libsodium - IIFE executes immediately but doesn't block module loading -void (async () => await sodium.ready)() - /** * Error class for Bip32PrivateKey related operations. * @@ -180,9 +180,7 @@ export const toHex = Function.makeEncodeSync(FromHex, Bip32PrivateKeyError, "Bip * @category bip39 */ export const fromBip39Entropy = (entropy: Uint8Array, password: string = ""): Bip32PrivateKey => { - return E.getOrThrowWith(Either.fromBip39Entropy(entropy, password), (err) => { - throw err - }) + return Effect.runSync(Either.fromBip39Entropy(entropy, password)) } /** @@ -193,9 +191,7 @@ export const fromBip39Entropy = (entropy: Uint8Array, password: string = ""): Bi * @category bip32 */ export const deriveChild = (bip32PrivateKey: Bip32PrivateKey, index: number): Bip32PrivateKey => { - return E.getOrThrowWith(Either.deriveChild(bip32PrivateKey, index), (err) => { - throw err - }) + return Effect.runSync(Either.deriveChild(bip32PrivateKey, index)) } /** @@ -205,9 +201,7 @@ export const deriveChild = (bip32PrivateKey: Bip32PrivateKey, index: number): Bi * @category bip32 */ export const derive = (bip32PrivateKey: Bip32PrivateKey, indices: Array): Bip32PrivateKey => { - return E.getOrThrowWith(Either.derive(bip32PrivateKey, indices), (err) => { - throw err - }) + return Effect.runSync(Either.derive(bip32PrivateKey, indices)) } /** @@ -218,9 +212,7 @@ export const derive = (bip32PrivateKey: Bip32PrivateKey, indices: Array) * @category bip32 */ export const derivePath = (bip32PrivateKey: Bip32PrivateKey, path: string): Bip32PrivateKey => { - return E.getOrThrowWith(Either.derivePath(bip32PrivateKey, path), (err) => { - throw err - }) + return Effect.runSync(Either.derivePath(bip32PrivateKey, path)) } /** @@ -230,9 +222,7 @@ export const derivePath = (bip32PrivateKey: Bip32PrivateKey, path: string): Bip3 * @category conversion */ export const toPrivateKey = (bip32PrivateKey: Bip32PrivateKey): PrivateKey.PrivateKey => { - return E.getOrThrowWith(Either.toPrivateKey(bip32PrivateKey), (err) => { - throw err - }) + return Effect.runSync(Either.toPrivateKey(bip32PrivateKey)) } /** @@ -242,9 +232,7 @@ export const toPrivateKey = (bip32PrivateKey: Bip32PrivateKey): PrivateKey.Priva * @category cryptography */ export const toPublicKey = (bip32PrivateKey: Bip32PrivateKey): Bip32PublicKey.Bip32PublicKey => { - return E.getOrThrowWith(Either.toPublicKey(bip32PrivateKey), (err) => { - throw err - }) + return Effect.runSync(Either.toPublicKey(bip32PrivateKey)) } /** @@ -254,9 +242,7 @@ export const toPublicKey = (bip32PrivateKey: Bip32PrivateKey): Bip32PublicKey.Bi * @category cml-compatibility */ export const to128XPRV = (bip32PrivateKey: Bip32PrivateKey): Uint8Array => { - return E.getOrThrowWith(Either.to_128_xprv(bip32PrivateKey), (err) => { - throw err - }) + return Effect.runSync(Either.to_128_xprv(bip32PrivateKey)) } /** @@ -266,9 +252,7 @@ export const to128XPRV = (bip32PrivateKey: Bip32PrivateKey): Uint8Array => { * @category cml-compatibility */ export const from128XPRV = (bytes: Uint8Array): Bip32PrivateKey => { - return E.getOrThrowWith(Either.from_128_xprv(bytes), (err) => { - throw err - }) + return Effect.runSync(Either.from_128_xprv(bytes)) } // ============================================================================ @@ -280,7 +264,7 @@ export const arbitrary = FastCheck.uint8Array({ minLength: 96, maxLength: 96 }). ) // ============================================================================ -// Either Namespace (Composable API) +// Either Namespace (Composable API - for backward compatibility) // ============================================================================ export namespace Either { @@ -290,16 +274,22 @@ export namespace Either { export const toHex = Function.makeEncodeEither(FromHex, Bip32PrivateKeyError) export const fromBip39Entropy = (entropy: Uint8Array, password: string = "") => - E.gen(function* () { + Effect.gen(function* () { const keyMaterial = pbkdf2(sha512, password, entropy, { c: PBKDF2_ITERATIONS, dkLen: PBKDF2_KEY_SIZE }) const clamped = new Uint8Array(keyMaterial) clamped.set(clampScalar(keyMaterial.slice(0, 32)), 0) - return yield* fromBytes(clamped) + return yield* Effect.try({ + try: () => Schema.decodeUnknownSync(FromBytes)(clamped), + catch: (cause) => new Bip32PrivateKeyError({ message: "fromBip39Entropy failed", cause }) + }) }) export const deriveChild = (bip32PrivateKey: Bip32PrivateKey, index: number) => - E.gen(function* () { - const keyBytes = yield* toBytes(bip32PrivateKey) + Effect.gen(function* () { + const keyBytes = yield* Effect.try({ + try: () => Schema.encodeSync(FromBytes)(bip32PrivateKey), + catch: (cause) => new Bip32PrivateKeyError({ message: "toBytes failed", cause }) + }) const scalar = extractScalar(keyBytes) const iv = extractIV(keyBytes) const chainCode = extractChainCode(keyBytes) @@ -325,13 +315,15 @@ export namespace Either { zInput.set(indexBytes, 65) } else { // 0x02 || publicKey || index - const publicKey = sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + const scalarBigInt = mod(bytesToNumberLE(scalar), ed25519.Point.Fn.ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + const publicKey = publicKeyPoint.toBytes() zInput = new Uint8Array(1 + 32 + 4) zInput.set(zTag, 0) zInput.set(publicKey, 1) zInput.set(indexBytes, 33) } - const hmacZ = sodium.crypto_auth_hmacsha512(zInput, chainCode) + const hmacZ = hmac(sha512, chainCode, zInput) const z = new Uint8Array(hmacZ) const zl = z.slice(0, 32) const zr = z.slice(32, 64) @@ -352,13 +344,15 @@ export namespace Either { ccInput.set(indexBytes, 65) } else { // 0x03 || publicKey || index (use parent public key) - const publicKey = sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + const scalarBigInt = mod(bytesToNumberLE(scalar), ed25519.Point.Fn.ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + const publicKey = publicKeyPoint.toBytes() ccInput = new Uint8Array(1 + 32 + 4) ccInput.set(ccTag, 0) ccInput.set(publicKey, 1) ccInput.set(indexBytes, 33) } - const hmacCC = sodium.crypto_auth_hmacsha512(ccInput, chainCode) + const hmacCC = hmac(sha512, chainCode, ccInput) const newChainCode = new Uint8Array(hmacCC).slice(32, 64) const out = new Uint8Array(96) @@ -370,7 +364,7 @@ export namespace Either { }) export const derive = (bip32PrivateKey: Bip32PrivateKey, indices: Array) => - E.gen(function* () { + Effect.gen(function* () { let current = bip32PrivateKey for (const idx of indices) { current = yield* deriveChild(current, idx) @@ -379,37 +373,53 @@ export namespace Either { }) const parsePath = (path: string) => - E.try(() => { - const clean = path.startsWith("m/") ? path.slice(2) : path - if (clean.length === 0) return [] as Array - return clean.split("/").map((seg) => { - const hardened = seg.endsWith("'") || seg.endsWith("h") || seg.endsWith("H") - const core = hardened ? seg.slice(0, -1) : seg - const n = Number(core) - if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid path segment: ${seg}`) - return hardened ? (0x80000000 + n) >>> 0 : n >>> 0 - }) + Effect.try({ + try: () => { + const clean = path.startsWith("m/") ? path.slice(2) : path + if (clean.length === 0) return [] as Array + return clean.split("/").map((seg) => { + const hardened = seg.endsWith("'") || seg.endsWith("h") || seg.endsWith("H") + const core = hardened ? seg.slice(0, -1) : seg + const n = Number(core) + if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid path segment: ${seg}`) + return hardened ? (0x80000000 + n) >>> 0 : n >>> 0 + }) + }, + catch: (cause) => new Bip32PrivateKeyError({ message: "Invalid derivation path", cause }) }) export const derivePath = (bip32PrivateKey: Bip32PrivateKey, path: string) => - E.gen(function* () { + Effect.gen(function* () { const indices = yield* parsePath(path) return yield* derive(bip32PrivateKey, indices) }) export const toPrivateKey = (bip32PrivateKey: Bip32PrivateKey) => - E.gen(function* () { - const keyBytes = yield* toBytes(bip32PrivateKey) + Effect.gen(function* () { + const keyBytes = yield* Effect.try({ + try: () => Schema.encodeSync(FromBytes)(bip32PrivateKey), + catch: (cause) => new Bip32PrivateKeyError({ message: "toBytes failed", cause }) + }) const priv = keyBytes.slice(0, 64) return new PrivateKey.PrivateKey({ key: priv }, { disableValidation: true }) }) export const toPublicKey = (bip32PrivateKey: Bip32PrivateKey) => - E.gen(function* () { - const keyBytes = yield* toBytes(bip32PrivateKey) + Effect.gen(function* () { + const keyBytes = yield* Effect.try({ + try: () => Schema.encodeSync(FromBytes)(bip32PrivateKey), + catch: (cause) => new Bip32PrivateKeyError({ message: "toBytes failed", cause }) + }) const scalar = extractScalar(keyBytes) const chainCode = extractChainCode(keyBytes) - const publicKeyBytes = yield* E.try(() => sodium.crypto_scalarmult_ed25519_base_noclamp(scalar)) + const publicKeyBytes = yield* Effect.try({ + try: () => { + const scalarBigInt = mod(bytesToNumberLE(scalar), ed25519.Point.Fn.ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + return publicKeyPoint.toBytes() + }, + catch: (cause) => new Bip32PrivateKeyError({ message: "toPublicKey failed", cause }) + }) const combined = new Uint8Array(64) combined.set(publicKeyBytes, 0) combined.set(chainCode, 32) @@ -417,12 +427,22 @@ export namespace Either { }) export const to_128_xprv = (bip32PrivateKey: Bip32PrivateKey) => - E.gen(function* () { - const keyBytes = yield* toBytes(bip32PrivateKey) + Effect.gen(function* () { + const keyBytes = yield* Effect.try({ + try: () => Schema.encodeSync(FromBytes)(bip32PrivateKey), + catch: (cause) => new Bip32PrivateKeyError({ message: "toBytes failed", cause }) + }) const scalar = extractScalar(keyBytes) const iv = extractIV(keyBytes) const chainCode = extractChainCode(keyBytes) - const publicKeyBytes = yield* E.try(() => sodium.crypto_scalarmult_ed25519_base_noclamp(scalar)) + const publicKeyBytes = yield* Effect.try({ + try: () => { + const scalarBigInt = mod(bytesToNumberLE(scalar), ed25519.Point.Fn.ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + return publicKeyPoint.toBytes() + }, + catch: (cause) => new Bip32PrivateKeyError({ message: "to_128_xprv failed", cause }) + }) const out = new Uint8Array(128) out.set(scalar, 0) out.set(iv, 32) @@ -432,19 +452,26 @@ export namespace Either { }) export const from_128_xprv = (bytes: Uint8Array) => - E.gen(function* () { + Effect.gen(function* () { if (bytes.length !== 128) { - return yield* E.left(new Bip32PrivateKeyError({ message: `Expected exactly 128 bytes, got ${bytes.length}` })) + return yield* Effect.fail(new Bip32PrivateKeyError({ message: `Expected exactly 128 bytes, got ${bytes.length}` })) } const scalar = bytes.slice(0, 32) const iv = bytes.slice(32, 64) const publicKeyExpected = bytes.slice(64, 96) const chaincode = bytes.slice(96, 128) - const derivedPK = yield* E.try(() => sodium.crypto_scalarmult_ed25519_base_noclamp(scalar)) + const derivedPK = yield* Effect.try({ + try: () => { + const scalarBigInt = mod(bytesToNumberLE(scalar), ed25519.Point.Fn.ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + return publicKeyPoint.toBytes() + }, + catch: (cause) => new Bip32PrivateKeyError({ message: "from_128_xprv failed", cause }) + }) const matches = derivedPK.every((b, i) => b === publicKeyExpected[i]) if (!matches) { - return yield* E.left( + return yield* Effect.fail( new Bip32PrivateKeyError({ message: "Public key does not match private key in 128-byte blob" }) ) } diff --git a/packages/evolution/src/core/Bip32PublicKey.ts b/packages/evolution/src/core/Bip32PublicKey.ts index 81673668..326418d6 100644 --- a/packages/evolution/src/core/Bip32PublicKey.ts +++ b/packages/evolution/src/core/Bip32PublicKey.ts @@ -1,13 +1,14 @@ +import { mod } from "@noble/curves/abstract/modular.js" +import { ed25519 } from "@noble/curves/ed25519.js" +import { bytesToNumberLE } from "@noble/curves/utils.js" +import { hmac } from "@noble/hashes/hmac.js" +import { sha512 } from "@noble/hashes/sha2.js" import { Data, Either as E, FastCheck, Schema } from "effect" -import sodium from "libsodium-wrappers-sumo" import * as Bytes from "./Bytes.js" import * as Bytes64 from "./Bytes64.js" import * as Function from "./Function.js" -// Initialize libsodium - IIFE executes immediately but doesn't block module loading -void (async () => await sodium.ready)() - /** * Error class for Bip32PublicKey related operations. * @@ -285,7 +286,7 @@ export namespace Either { zInput.set(indexBytes, 33) // HMAC-SHA512 with chain code as key - const hmacZ = sodium.crypto_auth_hmacsha512(zInput, parentChainCode) + const hmacZ = hmac(sha512, parentChainCode, zInput) const z = new Uint8Array(hmacZ) const zl = z.slice(0, 32) @@ -311,10 +312,16 @@ export namespace Either { } // Now compute zl8*G (scalar multiplication with base point using processed zl) - const zl8G = sodium.crypto_scalarmult_ed25519_base_noclamp(zl8) + // Apply modular reduction to ensure scalar is in valid range [0, curve.n) + const zl8BigInt = mod(bytesToNumberLE(zl8), ed25519.Point.Fn.ORDER) + const zl8GPoint = ed25519.Point.BASE.multiplyUnsafe(zl8BigInt) + const zl8G = zl8GPoint.toBytes() // Then add parentPublicKey + zl8G (point addition) - const childPublicKey = sodium.crypto_core_ed25519_add(parentPublicKey, zl8G) + const parentPublicKeyPoint = ed25519.Point.fromBytes(parentPublicKey) + const zl8GPointForAdd = ed25519.Point.fromBytes(zl8G) + const childPublicKeyPoint = parentPublicKeyPoint.add(zl8GPointForAdd) + const childPublicKey = childPublicKeyPoint.toBytes() // Derive new chain code: tag(0x03) + public_key(32 bytes) + index(4 bytes) const ccTag = new Uint8Array([0x03]) // TAG_DERIVE_CC_SOFT - corrected to 0x03 @@ -323,7 +330,7 @@ export namespace Either { ccInput.set(parentPublicKey, 1) ccInput.set(indexBytes, 33) - const hmacCC = sodium.crypto_auth_hmacsha512(ccInput, parentChainCode) + const hmacCC = hmac(sha512, parentChainCode, ccInput) const newChainCode = new Uint8Array(hmacCC).slice(32, 64) // Take right 32 bytes return { diff --git a/packages/evolution/src/core/Function.ts b/packages/evolution/src/core/Function.ts index 00ba90e5..ff1bf9e8 100644 --- a/packages/evolution/src/core/Function.ts +++ b/packages/evolution/src/core/Function.ts @@ -98,14 +98,14 @@ export const makeDecodeEither = (schema: Schema.Schema, ErrorClass: ErrorCtor) => (input: A) => Schema.decodeEither(schema)(input).pipe( - Either.mapLeft((e) => new ErrorClass({ message: "Failed to decode input", cause: e })) + Either.mapLeft((e) => new ErrorClass({ message: e.message, cause: e })) ) export const makeEncodeEither = (schema: Schema.Schema, ErrorClass: ErrorCtor) => (input: T) => Schema.encodeEither(schema)(input).pipe( - Either.mapLeft((e) => new ErrorClass({ message: "Failed to encode input", cause: e })) + Either.mapLeft((e) => new ErrorClass({ message: e.message, cause: e })) ) /** @@ -212,7 +212,7 @@ export const makeCBORDecodeEither = (bytes: Uint8Array, options?: CBOR.CodecOptions) => Either.try(() => CBOR.internalDecodeSync(bytes, options || defaultOptions)).pipe( Either.flatMap((cbor) => Schema.decodeEither(schemaTransformer)(cbor as T)), - Either.mapLeft((e) => new ErrorClass({ message: "Failed to decode CBOR bytes", cause: e })) + Either.mapLeft((e) => new ErrorClass({ message: (e as Error).message, cause: e as Error })) ) /** @@ -228,7 +228,7 @@ export const makeCBORDecodeHexEither = Either.try(() => Bytes.fromHex(hex)).pipe( Either.flatMap((bytes) => Either.try(() => CBOR.internalDecodeSync(bytes, options || defaultOptions))), Either.flatMap((cbor) => Schema.decodeEither(schemaTransformer)(cbor as T)), - Either.mapLeft((e) => new ErrorClass({ message: "Failed to decode CBOR hex", cause: e })) + Either.mapLeft((e) => new ErrorClass({ message: (e as Error).message, cause: e as Error })) ) /** @@ -240,10 +240,10 @@ export const makeCBOREncodeEither = ErrorClass: ErrorCtor, defaultOptions?: CBOR.CodecOptions ) => - (input: A, options?: CBOR.CodecOptions) => - Schema.encodeEither(schemaTransformer)(input).pipe( - Either.flatMap((cborValue) => Either.try(() => CBOR.internalEncodeSync(cborValue, options || defaultOptions))), - Either.mapLeft((e) => new ErrorClass({ message: "Failed to encode CBOR bytes", cause: e })) + (value: A, options?: CBOR.CodecOptions) => + Schema.encodeEither(schemaTransformer)(value).pipe( + Either.flatMap((cbor) => Either.try(() => CBOR.internalEncodeSync(cbor, options || defaultOptions))), + Either.mapLeft((e) => new ErrorClass({ message: (e as Error).message, cause: e as Error })) ) /** @@ -259,5 +259,5 @@ export const makeCBOREncodeHexEither = Schema.encodeEither(schemaTransformer)(input).pipe( Either.flatMap((cborValue) => Either.try(() => CBOR.internalEncodeSync(cborValue, options || defaultOptions))), Either.map((bytes) => Bytes.toHexUnsafe(bytes)), - Either.mapLeft((e) => new ErrorClass({ message: "Failed to encode CBOR hex string", cause: e })) + Either.mapLeft((e) => new ErrorClass({ message: (e as Error).message, cause: e as Error })) ) diff --git a/packages/evolution/src/core/PrivateKey.ts b/packages/evolution/src/core/PrivateKey.ts index 85c24f6c..53c57c53 100644 --- a/packages/evolution/src/core/PrivateKey.ts +++ b/packages/evolution/src/core/PrivateKey.ts @@ -1,9 +1,13 @@ +import { mod } from "@noble/curves/abstract/modular.js" +import { ed25519 } from "@noble/curves/ed25519.js" +import { bytesToNumberLE, numberToBytesLE } from "@noble/curves/utils.js" +import { sha512 } from "@noble/hashes/sha2.js" +import { randomBytes } from "@noble/hashes/utils.js" import { bech32 } from "@scure/base" import * as BIP32 from "@scure/bip32" import * as BIP39 from "@scure/bip39" import { wordlist } from "@scure/bip39/wordlists/english" import { Data, Either as E, FastCheck, ParseResult, Schema } from "effect" -import sodium from "libsodium-wrappers-sumo" import * as Bytes from "./Bytes.js" import * as Bytes32 from "./Bytes32.js" @@ -66,7 +70,7 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem E.gen(function* () { const { prefix, words } = yield* ParseResult.try({ try: () => bech32.decode(fromA as any, 1023), - catch: (error) => new ParseResult.Type(ast, fromA, `Failed to decode Bech32: ${(error as Error).message}`) + catch: (error) => new ParseResult.Type(ast, fromA, `Failed to decode bech32 string: ${(error as Error).message}`) }) if (prefix !== "ed25519e_sk") { throw new ParseResult.Type(ast, fromA, `Expected ed25519e_sk prefix, got ${prefix}`) @@ -177,7 +181,7 @@ export const toBech32 = Function.makeEncodeSync(FromBech32, PrivateKeyError, "Pr * @since 2.0.0 * @category generators */ -export const generate = () => sodium.randombytes_buf(32) +export const generate = () => randomBytes(32) /** * Generate a random 64-byte extended Ed25519 private key. @@ -186,7 +190,7 @@ export const generate = () => sodium.randombytes_buf(32) * @since 2.0.0 * @category generators */ -export const generateExtended = () => sodium.randombytes_buf(64) +export const generateExtended = () => randomBytes(64) /** * Derive the public key (VKey) from a private key. @@ -388,23 +392,38 @@ export namespace Either { const scalar = privateKeyBytes.slice(0, 32) const iv = privateKeyBytes.slice(32, 64) - const publicKey = sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) - const nonceHash = sodium.crypto_hash_sha512(new Uint8Array([...iv, ...message])) - const nonce = sodium.crypto_core_ed25519_scalar_reduce(nonceHash) - const r = sodium.crypto_scalarmult_ed25519_base_noclamp(nonce) - const hramHash = sodium.crypto_hash_sha512(new Uint8Array([...r, ...publicKey, ...message])) - const hram = sodium.crypto_core_ed25519_scalar_reduce(hramHash) - const s = sodium.crypto_core_ed25519_scalar_add(sodium.crypto_core_ed25519_scalar_mul(hram, scalar), nonce) + // Get curve order from the field + const CURVE_ORDER = ed25519.Point.Fn.ORDER - // return new Uint8Array([...r, ...s]) - const signature = new Uint8Array([...r, ...s]) + // Calculate public key from scalar: publicKey = scalar * G + // Apply modular reduction to ensure scalar is in valid range [0, curve.n) + const scalarBigInt = mod(bytesToNumberLE(scalar), CURVE_ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + const publicKey = publicKeyPoint.toBytes() + + // Calculate nonce: nonce = reduce(sha512(iv || message)) + const nonceHash = sha512(new Uint8Array([...iv, ...message])) + const nonce = mod(bytesToNumberLE(nonceHash), CURVE_ORDER) + + // Calculate R: R = nonce * G + const rPoint = ed25519.Point.BASE.multiply(nonce) + const r = rPoint.toBytes() + + // Calculate challenge: hram = reduce(sha512(R || publicKey || message)) + const hramHash = sha512(new Uint8Array([...r, ...publicKey, ...message])) + const hram = mod(bytesToNumberLE(hramHash), CURVE_ORDER) + + // Calculate s: s = (hram * scalar + nonce) mod L + const s = mod(hram * scalarBigInt + nonce, CURVE_ORDER) + + // Encode s as little-endian 32 bytes + const sBytes = numberToBytesLE(s, 32) + const signature = new Uint8Array([...r, ...sBytes]) return Ed25519Signature.make({ bytes: signature }) } // Standard 32-byte Ed25519 signing - const publicKey = sodium.crypto_sign_seed_keypair(privateKeyBytes).publicKey - const secretKey = new Uint8Array([...privateKeyBytes, ...publicKey]) - const signature = sodium.crypto_sign_detached(message, secretKey) + const signature = ed25519.sign(message, privateKeyBytes) return Ed25519Signature.make({ bytes: signature }) }) } diff --git a/packages/evolution/src/core/VKey.ts b/packages/evolution/src/core/VKey.ts index 291fd461..f3d1a45d 100644 --- a/packages/evolution/src/core/VKey.ts +++ b/packages/evolution/src/core/VKey.ts @@ -1,5 +1,7 @@ +import { mod } from "@noble/curves/abstract/modular.js" +import { ed25519 } from "@noble/curves/ed25519.js" +import { bytesToNumberLE } from "@noble/curves/utils.js" import { Data, FastCheck, Schema } from "effect" -import sodium from "libsodium-wrappers-sumo" import * as Bytes from "./Bytes.js" import * as Bytes32 from "./Bytes32.js" @@ -141,10 +143,13 @@ export const fromPrivateKey = (privateKey: PrivateKey.PrivateKey): VKey => { if (privateKeyBytes.length === 64) { // CML-compatible extended private key: use first 32 bytes as scalar const scalar = privateKeyBytes.slice(0, 32) - publicKeyBytes = sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + // Apply modular reduction to ensure scalar is in valid range [0, curve.n) + const scalarBigInt = mod(bytesToNumberLE(scalar), ed25519.Point.Fn.ORDER) + const publicKeyPoint = ed25519.Point.BASE.multiplyUnsafe(scalarBigInt) + publicKeyBytes = publicKeyPoint.toBytes() } else { - // Standard 32-byte Ed25519 private key using sodium - publicKeyBytes = sodium.crypto_sign_seed_keypair(privateKeyBytes).publicKey + // Standard 32-byte Ed25519 private key: derive public key + publicKeyBytes = ed25519.getPublicKey(privateKeyBytes) } return new VKey({ bytes: publicKeyBytes }) @@ -168,7 +173,7 @@ export const fromPrivateKey = (privateKey: PrivateKey.PrivateKey): VKey => { export const verify = (vkey: VKey, message: Uint8Array, signature: Uint8Array): boolean => { // Convert VKey to bytes const publicKeyBytes = vkey.bytes - return sodium.crypto_sign_verify_detached(signature, message, publicKeyBytes) + return ed25519.verify(signature, message, publicKeyBytes) } // ============================================================================ diff --git a/packages/evolution/src/index.ts b/packages/evolution/src/index.ts index c9372c5c..5fd126dc 100644 --- a/packages/evolution/src/index.ts +++ b/packages/evolution/src/index.ts @@ -114,5 +114,6 @@ export * as Withdrawals from "./core/Withdrawals.js" export { createClient } from "./sdk/client/ClientImpl.js" export * as Devnet from "./sdk/Devnet/Devnet.js" export * as DevnetDefault from "./sdk/Devnet/DevnetDefault.js" +export { runEffect } from "./utils/effect-runtime.js" export * as FeeValidation from "./utils/FeeValidation.js" export { Effect, Either, pipe, Schema } from "effect" diff --git a/packages/evolution/src/sdk/builders/SignBuilder.ts b/packages/evolution/src/sdk/builders/SignBuilder.ts index 25ef2974..faa2198c 100644 --- a/packages/evolution/src/sdk/builders/SignBuilder.ts +++ b/packages/evolution/src/sdk/builders/SignBuilder.ts @@ -3,48 +3,49 @@ import type { Effect } from "effect" import type * as Transaction from "../../core/Transaction.js" import type * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js" import type { EffectToPromiseAPI } from "../Type.js" +import type { SubmitBuilder } from "./SubmitBuilder.js" import type { TransactionBuilderError } from "./TransactionBuilder.js" +import type { TransactionResultBase } from "./TransactionResult.js" // ============================================================================ // Progressive Builder Interfaces // ============================================================================ +/** + * Effect-based API for SignBuilder operations. + * + * Includes all TransactionResultBase.Effect methods plus signing-specific operations. + * + * @since 2.0.0 + * @category interfaces + */ export interface SignBuilderEffect { - // Main signing method - produces a fully signed transaction ready for submission - readonly sign: () => Effect.Effect + // Base transaction methods (from TransactionResultBase) + readonly toTransaction: () => Effect.Effect + readonly toTransactionWithFakeWitnesses: () => Effect.Effect + readonly estimateFee: () => Effect.Effect - // Add external witness and proceed to submission + // Signing methods + readonly sign: () => Effect.Effect readonly signWithWitness: ( witnessSet: TransactionWitnessSet.TransactionWitnessSet ) => Effect.Effect - - // Assemble multiple witnesses into a complete transaction ready for submission readonly assemble: ( witnesses: ReadonlyArray ) => Effect.Effect - - // Partial signing - creates witness without advancing to submission (useful for multi-sig) readonly partialSign: () => Effect.Effect - - // Get witness set without signing (for inspection) readonly getWitnessSet: () => Effect.Effect - - // Get the unsigned transaction (for inspection) - readonly toTransaction: () => Effect.Effect - - // Get the transaction with fake witnesses (for fee validation) - readonly toTransactionWithFakeWitnesses: () => Effect.Effect } -export interface SignBuilder extends EffectToPromiseAPI { +/** + * SignBuilder extends TransactionResultBase with signing capabilities. + * + * Only available when the client has a signing wallet (seed, private key, or API wallet). + * Provides access to unsigned transaction (via base interface) and signing operations. + * + * @since 2.0.0 + * @category interfaces + */ +export interface SignBuilder extends TransactionResultBase, EffectToPromiseAPI { readonly Effect: SignBuilderEffect } - -export interface SubmitBuilderEffect { - readonly submit: () => Effect.Effect -} - -export interface SubmitBuilder extends EffectToPromiseAPI { - readonly Effect: SubmitBuilderEffect - readonly witnessSet: TransactionWitnessSet.TransactionWitnessSet -} \ No newline at end of file diff --git a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts new file mode 100644 index 00000000..1d8d417e --- /dev/null +++ b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts @@ -0,0 +1,138 @@ +/** + * SignBuilder Implementation + * + * Handles transaction signing by delegating to the wallet's signTx Effect method. + * The SignBuilder is responsible for: + * 1. Providing the transaction and UTxO context to the wallet + * 2. Managing the transition from unsigned to signed transaction + * 3. Creating the SubmitBuilder for transaction submission + * + * The actual signing logic (determining required signers, creating witnesses) + * is the wallet's responsibility. + * + * @since 2.0.0 + * @category builders + */ + +import { Effect } from "effect" + +import * as Transaction from "../../core/Transaction.js" +import type * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js" +import type * as Provider from "../provider/Provider.js" +import type * as UTxO from "../UTxO.js" +import type * as WalletNew from "../wallet/WalletNew.js" +import type { SignBuilder, SignBuilderEffect } from "./SignBuilder.js" +import { makeSubmitBuilder } from "./SubmitBuilderImpl.js" +import { TransactionBuilderError } from "./TransactionBuilder.js" + +// ============================================================================ +// SignBuilder Factory +// ============================================================================ + +/** + * Wallet type - can be SigningWallet or ApiWallet (both have Effect.signTx) + */ +type Wallet = WalletNew.SigningWallet | WalletNew.ApiWallet + +/** + * Create a SignBuilder instance for a built transaction. + * + * @param transaction - The unsigned transaction (body only, no witnesses) + * @param transactionWithFakeWitnesses - The transaction with fake witnesses for size validation + * @param fee - The calculated transaction fee in lovelace + * @param utxos - The UTxOs that were used as inputs (for wallet to determine required signers) + * @param wallet - The wallet that will sign the transaction + * @param provider - The provider to submit the transaction to + * + * @since 2.0.0 + * @category constructors + */ +export const makeSignBuilder = (params: { + transaction: Transaction.Transaction + transactionWithFakeWitnesses: Transaction.Transaction + fee: bigint + utxos: ReadonlyArray + provider: Provider.Provider + wallet: Wallet +}): SignBuilder => { + const { fee, provider, transaction, transactionWithFakeWitnesses, utxos, wallet } = params + + // ============================================================================ + // Effect Namespace Implementation + // ============================================================================ + + const signEffect: SignBuilderEffect = { + /** + * Sign the transaction by delegating to the wallet's Effect.signTx method. + * + * The wallet will: + * 1. Determine which keys are required based on transaction inputs/outputs + * 2. Create VKey witnesses for each required signature + * 3. Return the witness set + * + * SignBuilder then assembles the signed transaction and returns SubmitBuilder. + */ + sign: () => + Effect.gen(function* () { + yield* Effect.logDebug("Starting transaction signing (delegating to wallet Effect)") + + // Delegate to wallet's Effect.signTx with UTxO context + const witnessSet = yield* wallet.Effect.signTx(transaction, { utxos }).pipe( + Effect.mapError( + (walletError) => + new TransactionBuilderError({ message: "Failed to sign transaction", cause: walletError }) + ) + ) + + yield* Effect.logDebug(`Received witness set from wallet: ${witnessSet.vkeyWitnesses?.length ?? 0} VKey witnesses`) + + // Create signed transaction by combining transaction body with witness set + const signedTransaction = new Transaction.Transaction({ + body: transaction.body, + witnessSet, + isValid: transaction.isValid, + auxiliaryData: transaction.auxiliaryData + }) + + yield* Effect.logDebug("Transaction signed successfully") + + // Return SubmitBuilder + return makeSubmitBuilder(signedTransaction, witnessSet, provider) + }), + + // TODO: Implement these methods + signWithWitness: (_witnessSet: TransactionWitnessSet.TransactionWitnessSet) => + Effect.fail(new TransactionBuilderError({ message: "signWithWitness not yet implemented" })), + + assemble: (_witnesses: ReadonlyArray) => + Effect.fail(new TransactionBuilderError({ message: "assemble not yet implemented" })), + + partialSign: () => Effect.fail(new TransactionBuilderError({ message: "partialSign not yet implemented" })), + + getWitnessSet: () => Effect.succeed(transaction.witnessSet), + + toTransaction: () => Effect.succeed(transaction), + + toTransactionWithFakeWitnesses: () => Effect.succeed(transactionWithFakeWitnesses), + + estimateFee: () => Effect.succeed(fee) + } + + // ============================================================================ + // Promise-based API (using Effect.runPromise) + // ============================================================================ + + return { + Effect: signEffect, + sign: () => Effect.runPromise(signEffect.sign()), + signWithWitness: (witnessSet: TransactionWitnessSet.TransactionWitnessSet) => + Effect.runPromise(signEffect.signWithWitness(witnessSet)), + assemble: (witnesses: ReadonlyArray) => + Effect.runPromise(signEffect.assemble(witnesses)), + partialSign: () => Effect.runPromise(signEffect.partialSign()), + getWitnessSet: () => Effect.runPromise(signEffect.getWitnessSet()), + toTransaction: () => Effect.runPromise(signEffect.toTransaction()), + toTransactionWithFakeWitnesses: () => Effect.runPromise(signEffect.toTransactionWithFakeWitnesses()), + estimateFee: () => Effect.runPromise(signEffect.estimateFee()) + } +} diff --git a/packages/evolution/src/sdk/builders/SubmitBuilder.ts b/packages/evolution/src/sdk/builders/SubmitBuilder.ts new file mode 100644 index 00000000..512fe2f9 --- /dev/null +++ b/packages/evolution/src/sdk/builders/SubmitBuilder.ts @@ -0,0 +1,60 @@ +/** + * SubmitBuilder - Final stage of transaction lifecycle + * + * Represents a signed transaction ready for submission to the blockchain. + * Provides the submit() method to broadcast the transaction and retrieve the transaction hash. + * + * @since 2.0.0 + * @category builders + */ + +import type { Effect } from "effect" + +import type * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js" +import type { EffectToPromiseAPI } from "../Type.js" +import type { TransactionBuilderError } from "./TransactionBuilder.js" + +/** + * Effect-based API for SubmitBuilder operations. + * + * @since 2.0.0 + * @category interfaces + */ +export interface SubmitBuilderEffect { + /** + * Submit the signed transaction to the blockchain via the provider. + * + * @returns Effect resolving to the transaction hash + * @since 2.0.0 + */ + readonly submit: () => Effect.Effect +} + +/** + * SubmitBuilder - represents a signed transaction ready for submission. + * + * The final stage in the transaction lifecycle after building and signing. + * Provides the submit() method to broadcast the transaction to the blockchain + * and retrieve the transaction hash. + * + * @since 2.0.0 + * @category interfaces + */ +export interface SubmitBuilder extends EffectToPromiseAPI { + /** + * Effect-based API for compositional workflows. + * + * @since 2.0.0 + */ + readonly Effect: SubmitBuilderEffect + + /** + * The witness set containing all signatures for this transaction. + * + * Can be used to inspect the signatures or combine with other witness sets + * for multi-party signing scenarios. + * + * @since 2.0.0 + */ + readonly witnessSet: TransactionWitnessSet.TransactionWitnessSet +} diff --git a/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts b/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts new file mode 100644 index 00000000..4e203fc3 --- /dev/null +++ b/packages/evolution/src/sdk/builders/SubmitBuilderImpl.ts @@ -0,0 +1,64 @@ +/** + * SubmitBuilder Implementation + * + * Handles transaction submission by delegating to the provider's submitTx method. + * The SubmitBuilder is responsible for: + * 1. Converting the signed transaction to CBOR hex format + * 2. Submitting to the provider's Effect.submitTx + * 3. Returning the transaction hash + * + * @since 2.0.0 + * @category builders + */ + +import { Effect } from "effect" + +import * as Transaction from "../../core/Transaction.js" +import type * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js" +import type * as Provider from "../provider/Provider.js" +import type { SubmitBuilder, SubmitBuilderEffect } from "./SubmitBuilder.js" +import { TransactionBuilderError } from "./TransactionBuilder.js" + +/** + * Create a SubmitBuilder instance for a signed transaction. + * + * @param signedTransaction - The signed transaction ready for submission + * @param witnessSet - The witness set containing signatures + * @param provider - The provider to submit the transaction to + * + * @since 2.0.0 + * @category constructors + */ +export const makeSubmitBuilder = ( + signedTransaction: Transaction.Transaction, + witnessSet: TransactionWitnessSet.TransactionWitnessSet, + provider: Provider.Provider +): SubmitBuilder => { + const submitEffect: SubmitBuilderEffect = { + submit: () => + Effect.gen(function* () { + yield* Effect.logDebug("Submitting transaction to provider") + + // Convert transaction to CBOR hex for provider submission + const txCborHex = Transaction.toCBORHex(signedTransaction) + + // Submit via provider's Effect.submitTx + const txHash = yield* provider.Effect.submitTx(txCborHex).pipe( + Effect.mapError( + (providerError) => + new TransactionBuilderError({ message: "Failed to submit transaction", cause: providerError }) + ) + ) + + yield* Effect.logDebug(`Transaction submitted successfully: ${txHash}`) + + return txHash + }) + } + + return { + Effect: submitEffect, + submit: () => Effect.runPromise(submitEffect.submit()), + witnessSet + } +} diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 83572e53..274c154c 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -32,11 +32,15 @@ import type * as Coin from "../../core/Coin.js" import * as Transaction from "../../core/Transaction.js" import * as Assets from "../Assets.js" import type { EvalRedeemer } from "../EvalRedeemer.js" +import type * as Provider from "../provider/Provider.js" import type * as UTxO from "../UTxO.js" +import type * as WalletNew from "../wallet/WalletNew.js" import type { CoinSelectionAlgorithm, CoinSelectionFunction } from "./CoinSelection.js" import { largestFirstSelection } from "./CoinSelection.js" import type { CollectFromParams, PayToAddressParams } from "./operations/Operations.js" import type { SignBuilder } from "./SignBuilder.js" +import { makeSignBuilder } from "./SignBuilderImpl.js" +import { makeTransactionResult } from "./TransactionResult.js" import { assembleTransaction, buildFakeWitnessSet, @@ -45,33 +49,11 @@ import { calculateMinimumUtxoLovelace, calculateTotalAssets, calculateTransactionSize, - createChangeOutput, createCollectFromProgram, - createPayToAddressProgram, - makeTxOutput, - verifyTransactionBalance + createPayToAddressProgram } from "./TxBuilderImpl.js" import * as Unfrack from "./Unfrack.js" -// ============================================================================ -// Constants -// ============================================================================ - -/** - * Maximum number of re-selection attempts when balancing transaction. - * - * During transaction building, if the actual fee (calculated with fake witnesses) - * exceeds the initial estimate and causes insufficient balance, the builder will - * retry coin selection up to this many times with updated fee estimates. - * - * @since 2.0.0 - */ -const MAX_RESELECTION_ATTEMPTS = 3 - -// ============================================================================ -// Error Types -// ============================================================================ - /** * Error type for failures occurring during transaction builder operations. * @@ -83,10 +65,6 @@ export class TransactionBuilderError extends Data.TaggedError("TransactionBuilde cause?: unknown }> {} -// ============================================================================ -// Transaction Types -// ============================================================================ - export interface ChainResult { readonly transaction: Transaction.Transaction readonly newOutputs: ReadonlyArray // UTxOs created by this transaction @@ -295,6 +273,29 @@ export interface UnfrackOptions { // Build configuration options export interface BuildOptions { + /** + * Override protocol parameters for this specific transaction build. + * + * By default, fetches from provider during build(). + * Provide this to use different protocol parameters for testing or special cases. + * + * Use cases: + * - Testing with different fee parameters + * - Simulating future protocol changes + * - Using cached parameters to avoid provider fetch + * + * Example: + * ```typescript + * // Test with custom fee parameters + * builder.build({ + * protocolParameters: { ...params, minFeeCoefficient: 50n, minFeeConstant: 200000n } + * }) + * ``` + * + * @since 2.0.0 + */ + readonly protocolParameters?: ProtocolParameters + /** * Coin selection strategy for automatic input selection. * @@ -319,6 +320,56 @@ export interface BuildOptions { // Change Handling Configuration // ============================================================================ + /** + * Override the change address for this specific transaction build. + * + * By default, uses wallet.Effect.address() from TxBuilderConfig. + * Provide this to use a different address for change outputs. + * + * Use cases: + * - Multi-address wallet (use account index 5 for change) + * - Different change address per transaction + * - Multi-sig workflows where change address varies + * - Testing with different addresses + * + * Example: + * ```typescript + * // Use different account for change + * builder.build({ changeAddress: wallet.addresses[5] }) + * + * // Custom address + * builder.build({ changeAddress: "addr_test1..." }) + * ``` + * + * @since 2.0.0 + */ + readonly changeAddress?: string + + /** + * Override the available UTxOs for this specific transaction build. + * + * By default, fetches UTxOs from provider.Effect.getUtxos(wallet.address). + * Provide this to use a specific set of UTxOs for coin selection. + * + * Use cases: + * - Use UTxOs from specific account index + * - Pre-filtered UTxO set + * - Testing with known UTxO set + * - Multi-address UTxO aggregation + * + * Example: + * ```typescript + * // Use UTxOs from specific account + * builder.build({ availableUtxos: utxosFromAccount5 }) + * + * // Combine UTxOs from multiple addresses + * builder.build({ availableUtxos: [...utxos1, ...utxos2] }) + * ``` + * + * @since 2.0.0 + */ + readonly availableUtxos?: ReadonlyArray + /** * # Change Handling Strategy Matrix * @@ -434,12 +485,11 @@ export interface BuildOptions { readonly useStateMachine?: boolean /** - * **EXPERIMENTAL**: Use V3 4-phase state machine * - * When true, uses V3's simplified 4-phase state machine: + * When true, uses simplified 4-phase state machine: * - selection → changeValidation → balanceVerification → fallback → complete * - * V3 shares TxContext with V2 but uses mathematical validation approach. + * shares TxContext with V2 but uses mathematical validation approach. * * @experimental * @default false @@ -506,33 +556,47 @@ export interface ProtocolParameters { * Configuration for TransactionBuilder. * Immutable configuration passed to builder at creation time. * - * Contains: - * - Protocol parameters for fee calculation - * - Change address for leftover funds - * - Available UTxOs for coin selection + * Wallet-centric design (when wallet provided): + * - Wallet provides change address (via wallet.Effect.address()) + * - Provider + Wallet provide available UTxOs (via provider.Effect.getUtxos(wallet.address)) + * - Override per-build via BuildOptions if needed + * + * Manual mode (no wallet): + * - Must provide changeAddress and availableUtxos in BuildOptions for each build + * - Used for read-only scenarios or advanced use cases * * @since 2.0.0 * @category config */ export interface TxBuilderConfig { - readonly protocolParameters: ProtocolParameters - /** - * Address to send change (leftover assets) to. - * This is required for proper transaction balancing. + * Optional wallet provides: + * - Change address via wallet.Effect.address() + * - Available UTxOs via wallet.Effect.address() + provider.Effect.getUtxos() + * - Signing capability via wallet.Effect.signTx() (SigningWallet and ApiWallet only) + * + * When provided: Automatic change address and UTxO resolution. + * When omitted: Must provide changeAddress and availableUtxos in BuildOptions. + * + * ReadOnlyWallet: For read-only clients that can build but not sign transactions. + * SigningWallet/ApiWallet: For signing clients with full transaction signing capability. + * + * Override per-build via BuildOptions.changeAddress and BuildOptions.availableUtxos. */ - readonly changeAddress: string + readonly wallet?: WalletNew.SigningWallet | WalletNew.ApiWallet | WalletNew.ReadOnlyWallet /** - * UTxOs available for coin selection. - * These can be from a wallet, another user, or any other source. - * Coin selection will automatically select from these UTxOs to cover - * required outputs + fees, excluding any already collected via collectFrom(). + * Optional provider for: + * - Fetching UTxOs for the wallet's address (provider.Effect.getUtxos) + * - Transaction submission (provider.Effect.submitTx) + * - Protocol parameters + * + * Works together with wallet to provide everything needed for transaction building. + * When wallet is omitted, provider is only used if you call provider methods directly. */ - readonly availableUtxos: ReadonlyArray + readonly provider?: Provider.Provider // Future fields: - // readonly provider?: any // Provider interface for blockchain communication // readonly costModels?: Uint8Array // Cost models for script evaluation } @@ -613,6 +677,48 @@ export interface TxContextData { */ export class TxContext extends Context.Tag("TxContext")() {} +/** + * Resolved change address for the current build. + * This is resolved once at the start of build() from either: + * - BuildOptions.changeAddress (per-transaction override) + * - TxBuilderConfig.wallet.Effect.address() (default from wallet) + * + * Available to all phase functions via Effect Context. + * + * @since 2.0.0 + * @category context + */ +export class ChangeAddressTag extends Context.Tag("ChangeAddress")() {} + +/** + * Resolved protocol parameters for the current build. + * This is resolved once at the start of build() from either: + * - BuildOptions.protocolParameters (per-transaction override) + * - provider.Effect.getProtocolParameters() (fetched from provider) + * + * Available to all phase functions via Effect Context. + * + * @since 2.0.0 + * @category context + */ +export class ProtocolParametersTag extends Context.Tag("ProtocolParameters")< + ProtocolParametersTag, + ProtocolParameters +>() {} + +/** + * Resolved available UTxOs for the current build. + * This is resolved once at the start of build() from either: + * - BuildOptions.availableUtxos (per-transaction override) + * - provider.Effect.getUtxos(wallet.address) (default from wallet + provider) + * + * Available to all phase functions via Effect Context. + * + * @since 2.0.0 + * @category context + */ +export class AvailableUtxosTag extends Context.Tag("AvailableUtxos")>() {} + // ============================================================================ // Program Step Type - Deferred Execution Pattern // ============================================================================ @@ -664,6 +770,11 @@ export type ProgramStep = Effect.Effect { // ============================================================================ // Chainable Builder Methods - Create ProgramSteps, return same builder // ============================================================================ @@ -694,7 +807,7 @@ export interface TransactionBuilder { * @since 2.0.0 * @category builder-methods */ - readonly payToAddress: (params: PayToAddressParams) => TransactionBuilder + readonly payToAddress: (params: PayToAddressParams) => TransactionBuilder /** * Specify transaction inputs from provided UTxOs. @@ -705,7 +818,7 @@ export interface TransactionBuilder { * @since 2.0.0 * @category builder-methods */ - readonly collectFrom: (params: CollectFromParams) => TransactionBuilder + readonly collectFrom: (params: CollectFromParams) => TransactionBuilder // Future expansion points for other operations: // readonly mintTokens: (params: MintTokensParams) => TransactionBuilder @@ -724,10 +837,14 @@ export interface TransactionBuilder { * Creates fresh state and runs all accumulated ProgramSteps sequentially. * Can be called multiple times on the same builder instance with independent results. * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) + * * @since 2.0.0 * @category completion-methods */ - readonly build: (options?: BuildOptions) => Promise + readonly build: (options?: BuildOptions) => Promise /** * Execute all queued operations and return a signing-ready transaction via Effect. @@ -735,25 +852,43 @@ export interface TransactionBuilder { * Creates fresh state and runs all accumulated ProgramSteps sequentially. * Suitable for Effect-TS compositional workflows and error handling. * + * Error types include WalletError and ProviderError from config Effects. + * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) + * * @since 2.0.0 * @category completion-methods */ readonly buildEffect: ( options?: BuildOptions - ) => Effect.Effect + ) => Effect.Effect< + TResult, + TransactionBuilderError | EvaluationError | WalletNew.WalletError | Provider.ProviderError, + unknown + > /** * Execute all queued operations with explicit error handling via Either. * * Creates fresh state and runs all accumulated ProgramSteps sequentially. - * Returns Either for pattern-matched error recovery. + * Returns Either for pattern-matched error recovery. + * + * Error types include WalletError and ProviderError from config Effects. + * + * Returns TResult which is: + * - SignBuilder for SigningClient (can sign transactions) + * - TransactionResultBase for ReadOnlyClient (unsigned transaction only) * * @since 2.0.0 * @category completion-methods */ readonly buildEither: ( options?: BuildOptions - ) => Promise> + ) => Promise< + Either + > // ============================================================================ // Transaction Chaining Methods - Multi-transaction workflows @@ -825,1854 +960,121 @@ export interface TransactionBuilder { // ============================================================================ // Factory Function - Creates TransactionBuilder instances -// ============================================================================ - -/** - * Construct a TransactionBuilder instance from protocol configuration. - * - * The builder accumulates chainable method calls as deferred ProgramSteps. Calling build() or chain() - * creates fresh state (new Refs) and executes all accumulated programs sequentially, ensuring - * no state pollution between invocations. - * - * @since 2.0.0 - * @category constructors - */ -export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { - // Validate protocol parameters - if (config.protocolParameters.minFeeCoefficient < 0n) { - throw new Error("minFeeCoefficient must be non-negative") - } - - if (config.protocolParameters.minFeeConstant < 0n) { - throw new Error("minFeeConstant must be non-negative") - } - - if (config.protocolParameters.coinsPerUtxoByte < 0n) { - throw new Error("coinsPerUtxoByte must be non-negative") - } - - if (config.protocolParameters.maxTxSize <= 0) { - throw new Error("maxTxSize must be positive") - } - - // ProgramSteps array - stores deferred operations - // NO state created here - state is fresh per build() - const programs: Array = [] - - // Helper: Get coin selection algorithm function from name - const getCoinSelectionAlgorithm = (algorithm: CoinSelectionAlgorithm): CoinSelectionFunction => { - switch (algorithm) { - case "largest-first": - return largestFirstSelection - case "random-improve": - throw new TransactionBuilderError({ - message: "random-improve algorithm not yet implemented", - cause: { algorithm } - }) - case "optimal": - throw new TransactionBuilderError({ - message: "optimal algorithm not yet implemented", - cause: { algorithm } - }) - default: - throw new TransactionBuilderError({ - message: `Unknown coin selection algorithm: ${algorithm}`, - cause: { algorithm } - }) - } - } - - // Helper: Create fresh state Effect - const createFreshState = () => - Effect.gen(function* () { - return { - selectedUtxos: yield* Ref.make>([]), // Array for ordering - outputs: yield* Ref.make>([]), - scripts: yield* Ref.make(new Map()), - totalOutputAssets: yield* Ref.make({ lovelace: 0n }), - totalInputAssets: yield* Ref.make({ lovelace: 0n }), - redeemers: yield* Ref.make(new Map()) - } - }) - - // Core Effect logic for building transaction - const buildEffectCore = (options?: BuildOptions) => - Effect.gen(function* () { - const ctx = yield* TxContext - - // 1. Execute all programs to populate state - yield* Effect.all(programs, { concurrency: "unbounded" }) - - // 2. Initial Coin Selection Phase - // collectFrom = explicit selection (user-specified inputs) - // availableUtxos = pool for automatic balancing (wallet UTxOs) - yield* Effect.logDebug("Starting initial coin selection phase") - - // Get current input and output assets - const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) - const outputAssets = yield* Ref.get(ctx.state.totalOutputAssets) - const estimatedFee = 200_000n // Conservative initial estimate - - // Calculate asset delta: (outputs + estimated fee) - inputs - const assetDelta: Assets.Assets = { lovelace: 0n } - let hasPositiveDelta = false - - // Calculate required assets (outputs + estimated fee) - const outputLovelace = outputAssets.lovelace || 0n - const requiredAssets: Assets.Assets = { - ...outputAssets, - lovelace: outputLovelace + estimatedFee - } - - // Calculate delta for each asset unit - for (const [unit, required] of Object.entries(requiredAssets)) { - const available = (inputAssets[unit] as bigint) || 0n - const delta = required - available - - if (delta > 0n) { - assetDelta[unit] = delta - hasPositiveDelta = true - } - } - - // Run initial coin selection if we have positive asset delta - if (hasPositiveDelta) { - const assetDeltaStr = Object.entries(assetDelta) - .map(([unit, amount]) => `${unit}:${amount.toString()}`) - .join(", ") - - yield* Effect.logDebug(`Initial coin selection for: {${assetDeltaStr}}`) - - // Get available UTxOs from config for automatic balancing - const configUtxos = ctx.config.availableUtxos - - // Get already-collected UTxOs to prevent double-spending - const alreadyCollected = yield* Ref.get(ctx.state.selectedUtxos) - - // Filter out already-collected UTxOs - const availableUtxos = configUtxos.filter( - (utxo) => - !alreadyCollected.some( - (collected) => collected.txHash === utxo.txHash && collected.outputIndex === utxo.outputIndex - ) - ) - - // Determine coin selection function - const coinSelectionFn = options?.coinSelection - ? typeof options.coinSelection === "function" - ? options.coinSelection - : getCoinSelectionAlgorithm(options.coinSelection) - : largestFirstSelection // Default: largest-first - - const { selectedUtxos: additionalUtxos } = yield* Effect.try({ - try: () => coinSelectionFn(availableUtxos, assetDelta), - catch: (error) => - new TransactionBuilderError({ - message: "Initial coin selection failed", - cause: error - }) - }) - - yield* Effect.logDebug( - `Initial coin selection added ${additionalUtxos.length} UTxOs ` + `(${availableUtxos.length} available)` - ) - - // Add selected UTxOs to state - yield* Ref.update(ctx.state.selectedUtxos, (current) => [...current, ...additionalUtxos]) - - // Update total input assets - for (const utxo of additionalUtxos) { - yield* Ref.update(ctx.state.totalInputAssets, (current) => { - const updated = { ...current } - for (const [unit, amount] of Object.entries(utxo.assets)) { - updated[unit] = (updated[unit] || 0n) + (amount as bigint) - } - return updated - }) - } - - yield* Effect.logDebug(`Initial coin selection added ${additionalUtxos.length} UTxOs`) - } else { - yield* Effect.logDebug("Inputs already cover outputs + estimated fee, skipping initial coin selection") - } - - // 3. Reselection Loop: Create change, calculate actual fee, verify balance - // Now that we have initial coin selection, we iteratively: - // 1. Create change outputs from leftover assets - // 2. Calculate actual fee with complete output set - // 3. Verify balance is sufficient - // 4. If insufficient, select more UTxOs and retry - yield* Effect.logDebug("Starting reselection loop with change creation") - - let attempt = 0 - let calculatedFee = 0n - let balanceVerificationPassed = false - - while (attempt < MAX_RESELECTION_ATTEMPTS && !balanceVerificationPassed) { - attempt++ - - yield* Effect.logDebug(`Reselection attempt ${attempt}/${MAX_RESELECTION_ATTEMPTS}`) - - // Get current state - const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) - const baseOutputs = yield* Ref.get(ctx.state.outputs) - const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) - const outputAssets = yield* Ref.get(ctx.state.totalOutputAssets) - - yield* Effect.logDebug( - `Reselection state: ${selectedUtxos.length} UTxOs selected, ` + - `inputs: ${inputAssets.lovelace || 0n} lovelace, ` + - `outputs: ${outputAssets.lovelace || 0n} lovelace` - ) - - // Convert UTxOs to TransactionInputs for fee calculation - const inputs = yield* Effect.catchAll(buildTransactionInputs(selectedUtxos), (error) => - Effect.gen(function* () { - yield* Effect.logError(`Failed to build transaction inputs: ${JSON.stringify(error, null, 2)}`) - return yield* Effect.fail(error) - }) - ) - - yield* Effect.logDebug(`Successfully built ${inputs.length} transaction inputs`) - - // Estimate fee for current transaction WITHOUT change outputs - // This gives us a baseline fee to reserve from leftover - const baseFee = yield* calculateFeeIteratively(selectedUtxos, inputs, baseOutputs, { - minFeeCoefficient: ctx.config.protocolParameters.minFeeCoefficient, - minFeeConstant: ctx.config.protocolParameters.minFeeConstant - }) - - yield* Effect.logDebug(`Base fee (without change): ${baseFee} lovelace`) - - // Calculate leftover assets (inputs - outputs - estimatedFee) - // Reserve estimated fee so change outputs don't consume it - const leftoverAssets: Assets.Assets = { ...inputAssets, lovelace: 0n } - for (const [unit, amount] of Object.entries(outputAssets)) { - const current = leftoverAssets[unit] || 0n - const remaining = current - (amount as bigint) - if (remaining > 0n) { - leftoverAssets[unit] = remaining - } else { - delete leftoverAssets[unit] - } - } - - // Subtract base fee from lovelace leftover - leftoverAssets.lovelace = Assets.getAsset(leftoverAssets, "lovelace") - baseFee - - // Attempt to create change output(s) from leftover assets - let changeOutputs: Array = [] - - const leftoverLovelace = Assets.getAsset(leftoverAssets, "lovelace") - - // BUG FIX: Check if leftover lovelace is negative BEFORE attempting change creation - // If we have insufficient funds (negative lovelace), skip change creation entirely - // and let balance verification trigger reselection - if (leftoverLovelace < 0n) { - yield* Effect.logDebug( - `Insufficient lovelace for fee: leftover is ${leftoverLovelace}. ` + - `Skipping change creation, balance verification will trigger reselection.` - ) - // Set leftover to empty to skip change creation - // Native assets (if any) will be included in next reselection iteration - } else if (leftoverLovelace > 0n || Object.keys(leftoverAssets).length > 1) { - yield* Effect.logDebug("Attempting to create change output from leftover assets") - - // Calculate actual minimum UTxO requirement using CBOR encoding - // This ensures accurate decision-making for complex asset bundles - const nativeAssetCount = Object.keys(leftoverAssets).length - 1 // Exclude 'lovelace' - yield* Effect.logDebug(`Change calculation: ${leftoverLovelace} lovelace + ${nativeAssetCount} native assets`) - - const minUtxo = yield* calculateMinimumUtxoLovelace({ - address: ctx.config.changeAddress, - assets: leftoverAssets, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) - - yield* Effect.logDebug(`MinUTxO requirement: ${minUtxo} lovelace (via actual CBOR calculation)`) - - if (leftoverLovelace >= minUtxo) { - // SUCCESS: Leftover is sufficient for change output(s) - - // Priority 1: Apply unfracking if configured - if (options?.unfrack) { - yield* Effect.logDebug("Applying unfrack optimization to change outputs") - const unfrackedOutputs = yield* createChangeOutput({ - leftoverAssets, - changeAddress: ctx.config.changeAddress, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte, - unfrackOptions: options.unfrack - }) - changeOutputs = [...unfrackedOutputs] - yield* Effect.logDebug(`Created ${unfrackedOutputs.length} unfracked change outputs`) - } else { - // Priority 2: Create simple change output (no unfracking) - changeOutputs.push({ - address: ctx.config.changeAddress, - assets: leftoverAssets - }) - yield* Effect.logDebug( - `Created change output: ${leftoverLovelace} lovelace + ${Object.keys(leftoverAssets).length - 1} asset units` - ) - } - } else { - // INSUFFICIENT: Try fallback strategies - yield* Effect.logDebug(`Change too small (${leftoverLovelace} < ${minUtxo}), attempting fallback`) - - // Strategy 1: drainTo (merge with existing output) - if (options?.drainTo !== undefined) { - const drainToIndex = options.drainTo - const targetOutput = baseOutputs[drainToIndex] - - if (!targetOutput) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `drainTo index ${drainToIndex} out of bounds (${baseOutputs.length} outputs)` - }) - ) - } - - yield* Effect.logDebug(`Merging leftover into output #${drainToIndex} (drainTo strategy)`) - - // Merge leftover into target output - const updatedAssets = Assets.add(targetOutput.assets, leftoverAssets) - - const updatedOutput: UTxO.TxOutput = { - ...targetOutput, - assets: updatedAssets - } - - // Validate that the merged output meets minimum UTxO requirements - const mergedMinUtxo = yield* calculateMinimumUtxoLovelace({ - address: updatedOutput.address, - assets: updatedAssets, - datum: updatedOutput.datumOption, - scriptRef: updatedOutput.scriptRef, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) - - const updatedLovelace = Assets.getAsset(updatedAssets, "lovelace") - - yield* Effect.logDebug( - `drainTo validation: Merged output has ${updatedLovelace} lovelace ` + `(minUTxO: ${mergedMinUtxo})` - ) - - if (updatedLovelace < mergedMinUtxo) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `drainTo validation failed: Merged output at index ${drainToIndex} ` + - `has ${updatedLovelace} lovelace but requires minimum ${mergedMinUtxo}. ` + - `The target output has too many assets to absorb the small leftover. ` + - `Consider using a different drainTo target or adding more funds.` - }) - ) - } - - // Replace the target output in base outputs - const updatedBaseOutputs = [...baseOutputs] - updatedBaseOutputs[drainToIndex] = updatedOutput - - yield* Ref.set(ctx.state.outputs, updatedBaseOutputs) - yield* Effect.logDebug( - `Successfully merged leftover via drainTo (validated: ${updatedLovelace} >= ${mergedMinUtxo})` - ) - - // No change outputs needed (merged into existing) - changeOutputs = [] - } else { - // Strategy 2: Check onInsufficientChange option - const hasNativeAssets = Object.keys(leftoverAssets).length > 1 - - if (hasNativeAssets) { - // Native assets cannot be burned as fee - they would be lost forever - // STRATEGY: Create a change output with minUTxO requirement even though - // we don't have enough lovelace yet. This will cause balance verification - // to detect the shortfall and trigger reselection for more lovelace. - const lovelaceShortfall = minUtxo - leftoverLovelace - - yield* Effect.logDebug( - `Insufficient change with native assets: ${leftoverLovelace} < ${minUtxo}. ` + - `Shortfall: ${lovelaceShortfall} lovelace. ` + - `Creating change output with minUTxO requirement to trigger reselection.` - ) - - // Create change output with the REQUIRED minUTxO amount (not what we have) - // This will cause balance verification to see we're short on lovelace - changeOutputs.push({ - address: ctx.config.changeAddress, - assets: { - ...leftoverAssets, - lovelace: minUtxo // Use required amount, not available amount - } - }) - - // Balance verification will detect: - // - We need minUtxo lovelace for change - // - We only have leftoverLovelace available - // - Shortfall = minUtxo - leftoverLovelace - // This will trigger reselection to add more UTxOs - } else { - // Only lovelace left and it's below minUtxo - const insufficientChangeStrategy = options?.onInsufficientChange ?? "error" - - if (insufficientChangeStrategy === "burn") { - // User explicitly consented to burn leftover lovelace as extra fee - yield* Effect.logWarning( - `Burning ${leftoverLovelace} lovelace as extra fee (below minUtxo ${minUtxo})` - ) - // Leftover becomes extra fee (not added to outputs) - changeOutputs = [] - } else { - // Default: Error to prevent accidental loss - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Insufficient change: ${leftoverLovelace} lovelace is below ` + - `minimum UTxO (${minUtxo}). Configure drainTo to merge with an existing output, ` + - `or set onInsufficientChange: 'burn' to explicitly allow burning as extra fee.` - }) - ) - } - } - } - } - } else { - yield* Effect.logDebug("No leftover assets, skipping change creation") - } - - // Build complete output set: base outputs + change outputs - const currentBaseOutputs = yield* Ref.get(ctx.state.outputs) // Re-fetch in case drainTo modified it - const allOutputs = [...currentBaseOutputs, ...changeOutputs] - - // Calculate actual fee with complete outputs (including change) - calculatedFee = yield* calculateFeeIteratively(selectedUtxos, inputs, allOutputs, { - minFeeCoefficient: ctx.config.protocolParameters.minFeeCoefficient, - minFeeConstant: ctx.config.protocolParameters.minFeeConstant - }) - - yield* Effect.logDebug(`Calculated fee with ${allOutputs.length} outputs: ${calculatedFee} lovelace`) - - // Check if actual fee differs from base fee - // Recalculation needed when: (1) change outputs exist, OR (2) drainTo was used - const needsRecalculation = changeOutputs.length > 0 || options?.drainTo !== undefined - if (calculatedFee !== baseFee && needsRecalculation) { - const feeDelta = calculatedFee - baseFee - yield* Effect.logDebug( - `Fee adjustment triggered: ${baseFee} → ${calculatedFee} (Δ +${feeDelta} lovelace). ` + - `Recalculating change outputs with correct fee.` - ) - - // Recalculate leftover with ACTUAL fee - const correctedLeftover: Assets.Assets = { ...inputAssets, lovelace: 0n } - for (const [unit, amount] of Object.entries(outputAssets)) { - const current = correctedLeftover[unit] || 0n - const remaining = current - (amount as bigint) - if (remaining > 0n) { - correctedLeftover[unit] = remaining - } else { - delete correctedLeftover[unit] - } - } - - // Subtract ACTUAL fee from lovelace leftover - const feeUsedForLeftover = calculatedFee // Track which fee we used - const currentCorrectedLovelace = Assets.getAsset(correctedLeftover, "lovelace") - correctedLeftover.lovelace = currentCorrectedLovelace - calculatedFee - - const correctedLovelace = Assets.getAsset(correctedLeftover, "lovelace") - if (correctedLovelace >= 0n && (correctedLovelace > 0n || Object.keys(correctedLeftover).length > 1)) { - // Recreate change outputs with corrected leftover - if (options?.unfrack) { - yield* Effect.logDebug("Applying unfrack to corrected change outputs") - const recalculatedOutputs = yield* createChangeOutput({ - leftoverAssets: correctedLeftover, - changeAddress: ctx.config.changeAddress, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte, - unfrackOptions: options.unfrack - }) - changeOutputs = [...recalculatedOutputs] - - // Rebuild complete output set with corrected change - const updatedAllOutputs = [...currentBaseOutputs, ...changeOutputs] - - // Recalculate fee with corrected outputs - const recalculatedFee = yield* calculateFeeIteratively(selectedUtxos, inputs, updatedAllOutputs, { - minFeeCoefficient: ctx.config.protocolParameters.minFeeCoefficient, - minFeeConstant: ctx.config.protocolParameters.minFeeConstant - }) - - calculatedFee = recalculatedFee - // Update allOutputs for balance verification - let allOutputsFixed = updatedAllOutputs - - yield* Effect.logDebug(`Recalculated fee after correction: ${calculatedFee} lovelace`) - - // If fee changed during recalculation, adjust the outputs again - if (recalculatedFee !== feeUsedForLeftover) { - const feeAdjustment = feeUsedForLeftover - recalculatedFee - yield* Effect.logDebug( - `Fee changed during unfrack recalculation. Adjusting outputs by ${feeAdjustment} lovelace` - ) - - // Add the fee difference to the first change output - if (allOutputsFixed.length > currentBaseOutputs.length) { - const firstChangeIndex = currentBaseOutputs.length - allOutputsFixed = [...allOutputsFixed] - allOutputsFixed[firstChangeIndex] = { - ...allOutputsFixed[firstChangeIndex], - assets: { - ...allOutputsFixed[firstChangeIndex].assets, - lovelace: allOutputsFixed[firstChangeIndex].assets.lovelace + feeAdjustment - } - } - } - } - - // Use corrected outputs for balance check - const balanceCheckCorrected = verifyTransactionBalance(selectedUtxos, allOutputsFixed, calculatedFee) - - if (balanceCheckCorrected.sufficient) { - // ✅ SUCCESS with corrected change - balanceVerificationPassed = true - yield* Ref.set(ctx.state.outputs, allOutputsFixed) - yield* Effect.logDebug( - `Balance verification passed after correction on attempt ${attempt}. ` + - `Fee: ${calculatedFee}, Change: ${balanceCheckCorrected.change} lovelace` - ) - continue // Skip to next iteration (which will exit since balanceVerificationPassed = true) - } - } else if (options?.drainTo !== undefined) { - // Handle drainTo without unfrack: merge corrected leftover into target output - const drainToIndex = options.drainTo - const targetOutput = baseOutputs[drainToIndex] // Use ORIGINAL base outputs, not modified ones - - if (!targetOutput) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `drainTo index ${drainToIndex} out of bounds after recalculation` - }) - ) - } - - // Merge corrected leftover into target output - const updatedAssets = Assets.add(targetOutput.assets, correctedLeftover) - - const updatedOutput: UTxO.TxOutput = { - ...targetOutput, - assets: updatedAssets - } - - // Validate that the merged output meets minimum UTxO requirements - const mergedMinUtxo = yield* calculateMinimumUtxoLovelace({ - address: updatedOutput.address, - assets: updatedAssets, - datum: updatedOutput.datumOption, - scriptRef: updatedOutput.scriptRef, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) - - const recalcUpdatedLovelace = Assets.getAsset(updatedAssets, "lovelace") - if (recalcUpdatedLovelace < mergedMinUtxo) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `drainTo validation failed after fee recalculation: Merged output at index ${drainToIndex} ` + - `has ${recalcUpdatedLovelace} lovelace but requires minimum ${mergedMinUtxo}. ` + - `The target output has too many assets to absorb the corrected leftover. ` + - `Consider using a different drainTo target or adding more funds.` - }) - ) - } - - // Replace the target output - const updatedBaseOutputs = [...baseOutputs] // Start from ORIGINAL outputs - updatedBaseOutputs[drainToIndex] = updatedOutput - - // Recalculate fee with corrected drainTo output - const recalculatedFee = yield* calculateFeeIteratively(selectedUtxos, inputs, updatedBaseOutputs, { - minFeeCoefficient: ctx.config.protocolParameters.minFeeCoefficient, - minFeeConstant: ctx.config.protocolParameters.minFeeConstant - }) - - calculatedFee = recalculatedFee - - yield* Effect.logDebug(`Recalculated fee after drainTo correction: ${calculatedFee} lovelace`) - - // If fee changed during recalculation, adjust the output again - if (recalculatedFee !== feeUsedForLeftover) { - const feeAdjustment = feeUsedForLeftover - recalculatedFee - yield* Effect.logDebug( - `Fee changed during recalculation. Adjusting output by ${feeAdjustment} lovelace` - ) - const currentDrainToLovelace = Assets.getAsset(updatedBaseOutputs[drainToIndex].assets, "lovelace") - updatedBaseOutputs[drainToIndex] = { - ...updatedBaseOutputs[drainToIndex], - assets: { - ...updatedBaseOutputs[drainToIndex].assets, - lovelace: currentDrainToLovelace + feeAdjustment - } - } - - // Re-validate after fee adjustment - const adjustedMinUtxo = yield* calculateMinimumUtxoLovelace({ - address: updatedBaseOutputs[drainToIndex].address, - assets: updatedBaseOutputs[drainToIndex].assets, - datum: updatedBaseOutputs[drainToIndex].datumOption, - scriptRef: updatedBaseOutputs[drainToIndex].scriptRef, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) - - const adjustedLovelace = Assets.getAsset(updatedBaseOutputs[drainToIndex].assets, "lovelace") - if (adjustedLovelace < adjustedMinUtxo) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `drainTo validation failed after fee adjustment: Output at index ${drainToIndex} ` + - `has ${adjustedLovelace} lovelace but requires minimum ${adjustedMinUtxo}.` - }) - ) - } - } - - // Use corrected outputs for balance check - const balanceCheckCorrected = verifyTransactionBalance(selectedUtxos, updatedBaseOutputs, calculatedFee) - - if (balanceCheckCorrected.sufficient) { - // ✅ SUCCESS with corrected drainTo - balanceVerificationPassed = true - yield* Ref.set(ctx.state.outputs, updatedBaseOutputs) - yield* Effect.logDebug( - `Balance verification passed after drainTo correction on attempt ${attempt}. ` + - `Fee: ${calculatedFee}, Change: ${balanceCheckCorrected.change} lovelace` - ) - continue // Skip to next iteration (which will exit since balanceVerificationPassed = true) - } else { - yield* Effect.logWarning( - `Balance check failed after drainTo correction. Shortfall: ${balanceCheckCorrected.shortfall}` - ) - } - } else { - // Handle simple change output (no unfrack, no drainTo) - // Just update the change output's lovelace with the corrected amount - if (changeOutputs.length > 0) { - const feeDifference = calculatedFee - baseFee - yield* Effect.logDebug(`Adjusting simple change output by -${feeDifference} lovelace (fee increased)`) - - changeOutputs = changeOutputs.map((output) => { - const currentOutputLovelace = Assets.getAsset(output.assets, "lovelace") - return { - ...output, - assets: { - ...output.assets, - lovelace: currentOutputLovelace - feeDifference - } - } - }) - - // Rebuild complete output set with adjusted change - const adjustedAllOutputs = [...currentBaseOutputs, ...changeOutputs] - - // Verify the adjusted outputs meet balance - const balanceCheckAdjusted = verifyTransactionBalance(selectedUtxos, adjustedAllOutputs, calculatedFee) - - if (balanceCheckAdjusted.sufficient) { - // ✅ SUCCESS with adjusted change - balanceVerificationPassed = true - yield* Ref.set(ctx.state.outputs, adjustedAllOutputs) - yield* Effect.logDebug( - `Balance verification passed after simple change adjustment on attempt ${attempt}. ` + - `Fee: ${calculatedFee}, Adjusted change: ${changeOutputs[0].assets.lovelace} lovelace` - ) - continue // Skip to next iteration (which will exit since balanceVerificationPassed = true) - } else { - yield* Effect.logWarning( - `Balance check failed after simple change adjustment. Shortfall: ${balanceCheckAdjusted.shortfall}` - ) - } - } - } - } - } - - // Verify balance with actual fee - const balanceCheck = verifyTransactionBalance(selectedUtxos, allOutputs, calculatedFee) - - if (balanceCheck.sufficient) { - // ✅ SUCCESS: Balance is sufficient - balanceVerificationPassed = true - - // Update outputs in state (base + change) - yield* Ref.set(ctx.state.outputs, allOutputs) - - yield* Effect.logDebug( - `Balance verification passed on attempt ${attempt}. ` + - `Fee: ${calculatedFee}, Change: ${balanceCheck.change} lovelace` - ) - } else { - // ❌ INSUFFICIENT: Need more UTxOs - const shortfall = balanceCheck.shortfall - - if (attempt < MAX_RESELECTION_ATTEMPTS) { - yield* Effect.logWarning( - `Balance verification failed on attempt ${attempt}. ` + - `Shortfall: ${shortfall} lovelace. Selecting more UTxOs...` - ) - - // Get available UTxOs for reselection - const configUtxos = ctx.config.availableUtxos - const alreadyCollected = yield* Ref.get(ctx.state.selectedUtxos) - - const availableUtxos = configUtxos.filter( - (utxo) => - !alreadyCollected.some( - (collected) => collected.txHash === utxo.txHash && collected.outputIndex === utxo.outputIndex - ) - ) - - // Select more UTxOs to cover shortfall - const coinSelectionFn = options?.coinSelection - ? typeof options.coinSelection === "function" - ? options.coinSelection - : getCoinSelectionAlgorithm(options.coinSelection) - : largestFirstSelection - - const { selectedUtxos: additionalUtxos } = yield* Effect.try({ - try: () => coinSelectionFn(availableUtxos, { lovelace: shortfall }), - catch: (error) => - new TransactionBuilderError({ - message: `Reselection failed to cover ${shortfall} lovelace shortfall`, - cause: error - }) - }) - - const totalInputsBefore = alreadyCollected.reduce((sum, u) => sum + u.assets.lovelace, 0n) - const additionalLovelace = additionalUtxos.reduce((sum, u) => sum + u.assets.lovelace, 0n) - - yield* Effect.logDebug( - `Reselection added ${additionalUtxos.length} UTxOs ` + - `(${availableUtxos.length} available). ` + - `Total inputs: ${totalInputsBefore} → ${totalInputsBefore + additionalLovelace} lovelace` - ) - - // Add selected UTxOs to state - yield* Ref.update(ctx.state.selectedUtxos, (current) => [...current, ...additionalUtxos]) - - // Update total input assets - for (const utxo of additionalUtxos) { - yield* Ref.update(ctx.state.totalInputAssets, (current) => { - const updated = { ...current } - for (const [unit, amount] of Object.entries(utxo.assets)) { - updated[unit] = (updated[unit] || 0n) + (amount as bigint) - } - return updated - }) - } - - yield* Effect.logDebug(`Reselection added ${additionalUtxos.length} UTxOs`) - } else { - // Max attempts reached - fail with detailed error - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Cannot balance transaction after ${MAX_RESELECTION_ATTEMPTS} attempts. ` + - `Final shortfall: ${shortfall} lovelace. ` + - `This may indicate insufficient funds in available UTxOs.`, - cause: { - attempts: MAX_RESELECTION_ATTEMPTS, - finalShortfall: shortfall.toString(), - calculatedFee: calculatedFee.toString(), - selectedUtxos: selectedUtxos.length - } - }) - ) - } - } - } - - // 4. Fee Assignment and Final Assembly - // After reselection loop, we have final outputs (with change) and calculated fee - const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) - const finalOutputs = yield* Ref.get(ctx.state.outputs) - const fee = calculatedFee - - // 5. Convert UTxOs to TransactionInputs (sorted deterministically) - const inputs = yield* buildTransactionInputs(selectedUtxos) - - // 6. Assemble final transaction with calculated fee and final outputs - const transaction = yield* assembleTransaction(inputs, finalOutputs, fee) - - // 10. Validate transaction size against protocol limit - // Build transaction WITH fake witnesses (same as fee calculation does) for accurate size check - const fakeWitnessSet = yield* buildFakeWitnessSet(selectedUtxos) - - yield* Effect.logDebug( - `Fake witness set: ${fakeWitnessSet.vkeyWitnesses?.length ?? 0} vkey witnesses, ` + `${inputs.length} inputs` - ) - - const txWithWitnesses = new Transaction.Transaction({ - body: transaction.body, - witnessSet: fakeWitnessSet, - isValid: true, - auxiliaryData: null - }) - - // Get actual CBOR size with fake witnesses - const txSizeWithWitnesses = yield* calculateTransactionSize(txWithWitnesses) - - yield* Effect.logDebug( - `Transaction size check: ${txSizeWithWitnesses} bytes ` + - `(with ${fakeWitnessSet.vkeyWitnesses?.length ?? 0} fake witnesses), max=${ctx.config.protocolParameters.maxTxSize} bytes` - ) - - if (txSizeWithWitnesses > ctx.config.protocolParameters.maxTxSize) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Transaction size (${txSizeWithWitnesses} bytes) exceeds maximum ` + - `allowed (${ctx.config.protocolParameters.maxTxSize} bytes). ` + - `Try reducing inputs (${inputs.length}) or outputs (${finalOutputs.length}).`, - cause: { - txSizeBytes: txSizeWithWitnesses, - maxTxSize: ctx.config.protocolParameters.maxTxSize, - inputCount: inputs.length, - outputCount: finalOutputs.length, - suggestion: "Use larger UTxOs or consolidate outputs to reduce transaction size" - } - }) - ) - } - - // TODO Step 4: Build witness set with redeemers and detect required signers - // TODO Step 5: Run script evaluation to fill ExUnits - // TODO Step 6: Add change output (balancing) - - // Build transaction with fake witnesses for validation - const txWithFakeWitnesses = new Transaction.Transaction({ - body: transaction.body, - witnessSet: fakeWitnessSet, - isValid: true, - auxiliaryData: null - }) - - // 10. Return minimal SignBuilder stub - const signBuilder: SignBuilder = { - Effect: { - sign: () => Effect.fail(new TransactionBuilderError({ message: "Signing not yet implemented" })), - signWithWitness: () => - Effect.fail(new TransactionBuilderError({ message: "Witness signing not yet implemented" })), - assemble: () => Effect.fail(new TransactionBuilderError({ message: "Assemble not yet implemented" })), - partialSign: () => - Effect.fail(new TransactionBuilderError({ message: "Partial signing not yet implemented" })), - getWitnessSet: () => Effect.succeed(transaction.witnessSet), - toTransaction: () => Effect.succeed(transaction), - toTransactionWithFakeWitnesses: () => Effect.succeed(txWithFakeWitnesses) - }, - sign: () => Promise.reject(new Error("Signing not yet implemented")), - signWithWitness: () => Promise.reject(new Error("Witness signing not yet implemented")), - assemble: () => Promise.reject(new Error("Assemble not yet implemented")), - partialSign: () => Promise.reject(new Error("Partial signing not yet implemented")), - getWitnessSet: () => Promise.resolve(transaction.witnessSet), - toTransaction: () => Promise.resolve(transaction), - toTransactionWithFakeWitnesses: () => Promise.resolve(txWithFakeWitnesses) - } - - return signBuilder - }).pipe( - Effect.provideServiceEffect( - TxContext, - Effect.gen(function* () { - return { - config, - state: yield* createFreshState(), - options: options ?? {} - } - }) - ), - Effect.mapError( - (error) => - new TransactionBuilderError({ - message: "Build failed", - cause: error - }) - ) - ) - - // ============================================================================ - // State Machine Implementation (Parallel/Experimental) - // ============================================================================ - - // ============================================================================ - // EXPERIMENTAL STATE MACHINE IMPLEMENTATION - // ============================================================================ - // This is a parallel implementation to buildEffectCore using state machine pattern. - // NOT YET ACTIVE in production - has Context.Tag type system issues to resolve. - // TypeScript errors suppressed below as this is experimental/WIP code. - // ============================================================================ - - // @ts-nocheck - Experimental state machine code below - - /** - * Build phases representing the transaction building lifecycle - * - * Context-driven state machine: - * - Being IN a phase means DO that work - * - Phases read/write context, return next phase - * - selection is reusable (called initially or for reselection) - * - Fallbacks (drainTo, burnAsFee) only when attempts exhausted - */ - type BuildPhase = - | "selection" // Select coins if needed (checks context) - | "changeCreation" // Create change outputs - | "feeCalculation" // Calculate fee with change - | "balanceVerification" // Verify balance, decide next - | "drainTo" // Fallback: merge to existing output - | "burnAsFee" // Fallback: burn remaining as fee - | "complete" // Done - - /** - * Build context carrying state machine execution state - * Note: Removed duplication - selectedUtxos/baseOutputs read from TxContext.state - */ - interface BuildContext { - /** - * Current state machine phase - * - * Determines which phase executes next: - * - `selection`: Select UTxOs to cover outputs + fee - * - `changeCreation`: Create change outputs from leftover - * - `feeCalculation`: Calculate actual fee with change included - * - `balanceVerification`: Verify balance and route to completion/retry/fallback - * - `drainTo`: Fallback to merge small leftover into existing output - * - `burnAsFee`: Fallback to burn small leftover as extra fee - * - `complete`: Terminal state - transaction successfully built - */ - readonly phase: BuildPhase - - /** - * Current reselection attempt number (1-based) - * - * Tracks how many times we've tried to select UTxOs and create change. - * Used to prevent infinite loops (MAX_ATTEMPTS = 3). - * - * Example flow: - * - Attempt 1: Select 3 ADA, need change but insufficient → shortfall - * - Attempt 2: Select more UTxOs (5 ADA total), create change → success - */ - readonly attempt: number - - /** - * Final calculated transaction fee in lovelace - * - * Fee includes: - * - Base transaction structure - * - All inputs (from selected UTxOs) - * - All outputs (user outputs + change outputs) - * - Witness estimates - * - * Updated after: - * - Fee calculation phase (includes change outputs) - * - Fee adjustment (when change affects fee) - */ - readonly calculatedFee: bigint - - /** - * Amount of lovelace we're short when inputs insufficient - * - * Calculated by balance verification as the total deficit: - * shortfall = (outputs + changeOutputs + fee) - inputs - * - * This represents ALL missing lovelace, including: - * - Output requirements - * - Fee payment - * - Change output minUTxO needs (for both single and unfrack bundles) - * - * Used to: - * - Trigger reselection (select more UTxOs to cover shortfall) - * - Single source of truth for deficit during reselection - * - Debug insufficient balance errors - * - Track progress across attempts - * - * Set by: Balance verification when inputs < outputs - * Used by: Selection phase during reselection attempts - * Reset to: 0n after selection completes - */ - readonly shortfall: bigint - - /** - * Change outputs created from leftover assets - * - * Created in changeCreation phase when: - * - Leftover has native assets (MUST create change - ledger rule) - * - Leftover ADA >= minUTxO (can create valid change) - * - * Empty array when: - * - Leftover too small (triggers drainTo or burnAsFee) - * - No leftover after paying outputs + fee - * - * Used in: - * - Fee calculation (affects transaction size) - * - Balance verification (check if change created) - */ - readonly changeOutputs: ReadonlyArray - - /** - * Leftover assets AFTER subtracting the fee - * - * Calculated as: leftoverBeforeFee - calculatedFee - * Set in Phase 2 (Change Creation), used in Phase 4+ (Balance Verification, DrainTo, BurnAsFee) - * - * Purpose: - * - Single source of truth for what's available after fee payment - * - Eliminates redundant calculations across phases - * - Used for minUTxO checks, drainTo validation, burn decisions - * - * Example: leftoverBeforeFee = 5 ADA, calculatedFee = 0.17 ADA - * → leftoverAfterFee = 4.83 ADA - * - * Initialized to zero assets before Phase 2 completes - * - * @since 2.0.0 - */ - readonly leftoverAfterFee: Assets.Assets - - /** - * Index of output modified by drainTo fallback (optional) - * - * When leftover < minUTxO and ADA-only: - * - DrainTo merges leftover into existing output - * - This index tracks which output was modified - * - * Purpose: - * - Validation: Ensure merged output still meets minUTxO - * - Debugging: Track which output received extra funds - * - * Example: drainToIndex = 0 means leftover merged into first output - * - * Only set when: - * - drainTo configured in options - * - Leftover < minUTxO - * - Leftover is ADA-only (no native assets) - */ - readonly drainToIndex?: number - - /** - * Whether unfrack optimization is allowed for this build - * - * Controls whether ChangeCreation should attempt to create unfracked - * change outputs (splitting tokens into multiple bundles). - * - * Initialized based on options: - * - true: If unfrack option is configured - * - false: If unfrack option is not configured - * - * Can be set to false during build if: - * - Unfrack attempted but failed due to insufficient lovelace - * - Fallback to single change output triggered - * - * Purpose: - * - Allow precise fallback without heuristics - * - Try unfrack, detect failure, retry without it - * - Prevent re-attempting unfrack after it fails - * - * @since 2.0.0 - */ - readonly canUnfrack: boolean - } - - /** - * Phase result - describes what to do next. - * - * Each phase has ONE responsibility: decide what to do next. - * Phases read context, do their work, update context, return next phase. - * - * This pattern enables: - * - Single Responsibility: Phases do one thing - * - State Reuse: Selection logic reusable from different contexts - * - Context-driven: All state in context, no data passing - * - Effect-idiomatic: Composable state machine - */ - type PhaseResult = { - readonly next: BuildPhase - } - - /** - * BuildContext service tag - */ - class BuildContextTag extends Context.Tag("BuildContextTag")>() {} - - // ============================================================================ - // V2: CLEAN STATE MACHINE IMPLEMENTATION - // ============================================================================ - - /** - * NEW State Machine V2 - Clean context-driven implementation - * - * Key principles: - * - Start with fee = 0, let balance verification drive everything - * - Phases read context, do work, return next phase - * - Trust fee convergence (usually 1-2 iterations) - * - Selection is reusable - * - Fallbacks only when attempts exhausted - */ - const buildEffectCoreStateMachineV2 = (options?: BuildOptions) => - Effect.gen(function* () { - const ctx = yield* TxContext - - // Execute all programs to populate initial state - yield* Effect.all(programs, { concurrency: "unbounded" }) - - // Create initial build context - const initialBuildCtx: BuildContext = { - phase: "selection" as const, - attempt: 0, - calculatedFee: 0n, // Start with 0! - shortfall: 0n, - changeOutputs: [], - leftoverAfterFee: { lovelace: 0n }, - canUnfrack: ctx.options?.unfrack !== undefined - } - - const buildCtxRef = yield* Ref.make(initialBuildCtx) - - return yield* Effect.gen(function* () { - // State machine loop - while (true) { - const buildCtx = yield* Ref.get(buildCtxRef) - - if (buildCtx.phase === "complete") { - break - } - - yield* Effect.logDebug( - `[StateMachineV2] Phase: ${buildCtx.phase}, Attempt: ${buildCtx.attempt}, Fee: ${buildCtx.calculatedFee}` - ) - - // Execute phase - yield* executePhaseV2 - } - - // Build final transaction - const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) - const baseOutputs = yield* Ref.get(ctx.state.outputs) - const finalBuildCtx = yield* Ref.get(buildCtxRef) - - yield* Effect.logDebug( - `[buildEffectCoreStateMachineV2] Base outputs: ${baseOutputs.length}, ` + - `Change outputs: ${finalBuildCtx.changeOutputs.length}` - ) - - const inputs = yield* buildTransactionInputs(selectedUtxos) - const allOutputs = [...baseOutputs, ...finalBuildCtx.changeOutputs] - - yield* Effect.logDebug(`[buildEffectCoreStateMachineV2] Total outputs: ${allOutputs.length}`) - - const transaction = yield* assembleTransaction(inputs, allOutputs, finalBuildCtx.calculatedFee) - - // SAFETY CHECK: Validate transaction size against protocol limit - // Build transaction WITH fake witnesses for accurate size check (same as old impl) - const fakeWitnessSet = yield* buildFakeWitnessSet(selectedUtxos) - - yield* Effect.logDebug( - `[StateMachineV2] Fake witness set: ${fakeWitnessSet.vkeyWitnesses?.length ?? 0} vkey witnesses, ` + - `${inputs.length} inputs` - ) - - const txWithFakeWitnesses = new Transaction.Transaction({ - body: transaction.body, - witnessSet: fakeWitnessSet, - isValid: true, - auxiliaryData: null - }) - - // Get actual CBOR size with fake witnesses - const txSizeWithWitnesses = yield* calculateTransactionSize(txWithFakeWitnesses) - - yield* Effect.logDebug( - `[StateMachineV2] Transaction size: ${txSizeWithWitnesses} bytes ` + - `(with ${fakeWitnessSet.vkeyWitnesses?.length ?? 0} fake witnesses), ` + - `max=${ctx.config.protocolParameters.maxTxSize} bytes` - ) - - if (txSizeWithWitnesses > ctx.config.protocolParameters.maxTxSize) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Transaction size (${txSizeWithWitnesses} bytes) exceeds maximum ` + - `allowed (${ctx.config.protocolParameters.maxTxSize} bytes). ` + - `Try reducing inputs (${inputs.length}) or outputs (${allOutputs.length}).`, - cause: { - txSizeBytes: txSizeWithWitnesses, - maxTxSize: ctx.config.protocolParameters.maxTxSize, - inputCount: inputs.length, - outputCount: allOutputs.length, - suggestion: "Use larger UTxOs or consolidate outputs to reduce transaction size" - } - }) - ) - } - - // Log final build summary - yield* Effect.logDebug( - `[StateMachineV2] Build complete: ${inputs.length} input(s), ${allOutputs.length} output(s) ` + - `(${baseOutputs.length} base + ${finalBuildCtx.changeOutputs.length} change), ` + - `Fee: ${finalBuildCtx.calculatedFee} lovelace, Size: ${txSizeWithWitnesses} bytes, Attempts: ${finalBuildCtx.attempt}` - ) - - // Return SignBuilder (matching old implementation) - const signBuilder: SignBuilder = { - Effect: { - sign: () => Effect.fail(new TransactionBuilderError({ message: "Signing not yet implemented" })), - signWithWitness: () => - Effect.fail(new TransactionBuilderError({ message: "Witness signing not yet implemented" })), - assemble: () => Effect.fail(new TransactionBuilderError({ message: "Assemble not yet implemented" })), - partialSign: () => - Effect.fail(new TransactionBuilderError({ message: "Partial signing not yet implemented" })), - getWitnessSet: () => Effect.succeed(transaction.witnessSet), - toTransaction: () => Effect.succeed(transaction), - toTransactionWithFakeWitnesses: () => Effect.succeed(txWithFakeWitnesses) - }, - sign: () => Promise.reject(new Error("Signing not yet implemented")), - signWithWitness: () => Promise.reject(new Error("Witness signing not yet implemented")), - assemble: () => Promise.reject(new Error("Assemble not yet implemented")), - partialSign: () => Promise.reject(new Error("Partial signing not yet implemented")), - getWitnessSet: () => Promise.resolve(transaction.witnessSet), - toTransaction: () => Promise.resolve(transaction), - toTransactionWithFakeWitnesses: () => Promise.resolve(txWithFakeWitnesses) - } - - return signBuilder - }).pipe(Effect.provideService(BuildContextTag, buildCtxRef)) - }).pipe( - Effect.provideServiceEffect( - TxContext, - createFreshState().pipe(Effect.map((state) => ({ config, options: options ?? {}, state }))) - ) - ) - - /** - * Helper: Format assets for logging (BigInt-safe, truncates long unit names) - */ - const formatAssetsForLog = (assets: Assets.Assets): string => { - return Object.entries(assets) - .map(([unit, amount]) => `${unit.substring(0, 16)}...: ${amount.toString()}`) - .join(", ") - } - - /** - * Phase executor V2 - simpler, just updates phase in context - */ - const executePhaseV2 = Effect.gen(function* () { - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) - - let result: PhaseResult - - switch (buildCtx.phase) { - case "selection": - result = yield* phaseSelectionV2 - break - - case "changeCreation": - result = yield* phaseChangeCreationV2 - break - - case "feeCalculation": - result = yield* phaseFeeCalculationV2 - break - - case "balanceVerification": - result = yield* phaseBalanceVerificationV2 - break - - case "drainTo": - result = yield* phaseDrainToV2 - break - - case "burnAsFee": - result = yield* phaseBurnAsFeeV2 - break - - case "complete": - return - } - - // Update phase - yield* Ref.update(buildCtxRef, (ctx) => ({ ...ctx, phase: result.next })) - }) - - /** - * V2 Phase 1: Selection - * Precisely calculates what's needed based on outputs + calculatedFee - */ - const phaseSelectionV2 = Effect.gen(function* () { - const ctx = yield* TxContext - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) - - yield* Effect.logDebug(`[SelectionV2] Attempt ${buildCtx.attempt + 1}`) - - // Check max attempts - if (buildCtx.attempt >= MAX_RESELECTION_ATTEMPTS) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `Cannot balance after ${MAX_RESELECTION_ATTEMPTS} attempts`, - cause: { attempts: buildCtx.attempt, shortfall: buildCtx.shortfall.toString() } - }) - ) - } - - const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) - const outputAssets = yield* Ref.get(ctx.state.totalOutputAssets) - - // Ensure lovelace field exists for Assets utilities - const inputAssetsWithLovelace: Assets.Assets = { - ...inputAssets, - lovelace: inputAssets.lovelace || 0n - } - - // PRECISE calculation: what we need = outputs + fee + shortfall (if retrying) - // On first attempt (attempt=0): shortfall is 0n - // On retry (attempt>0): shortfall is set by balance verification with the TOTAL deficit - // - // NOTE: We use ONLY shortfall during reselection, NOT requiredChangeMinUTxO. - // The shortfall already accounts for ALL missing lovelace (including change minUTxO needs). - // Adding both would double-count! - yield* Effect.logDebug( - `[SelectionV2] buildCtx.shortfall: ${buildCtx.shortfall}, buildCtx.calculatedFee: ${buildCtx.calculatedFee}` - ) - const totalNeeded: Assets.Assets = { - ...outputAssets, - lovelace: (outputAssets.lovelace || 0n) + buildCtx.calculatedFee + buildCtx.shortfall - } - - // Calculate precise asset delta: needed - available - // Positive values = shortfalls (need more), Negative = excess (have more) - const assetDelta = Assets.subtract(totalNeeded, inputAssetsWithLovelace) - - // Extract only the shortfalls (positive values) - const assetShortfalls = Assets.filter(assetDelta, (_unit, amount) => amount > 0n) - - const needsSelection = !Assets.isEmpty(assetShortfalls) - - yield* Effect.logDebug( - `[SelectionV2] Needed: {${formatAssetsForLog(totalNeeded)}}, ` + - `Available: {${formatAssetsForLog(inputAssetsWithLovelace)}}, ` + - `Shortfalls: {${formatAssetsForLog(assetShortfalls)}}` - ) - - if (!needsSelection) { - yield* Effect.logDebug("[SelectionV2] Assets sufficient") - const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) - yield* Effect.logDebug( - `[SelectionV2] Selection complete: ${selectedUtxos.length} UTxO(s) selected, ` + - `Total lovelace: ${inputAssets.lovelace || 0n}` - ) - } else { - // Perform selection for precise shortfall - const shortfallStr = Object.entries(assetShortfalls) - .map(([_unit, amount]) => amount.toString()) - .join(", ") - yield* Effect.logDebug(`[SelectionV2] Selecting for shortfall: ${shortfallStr}`) - - const beforeCount = (yield* Ref.get(ctx.state.selectedUtxos)).length - yield* performCoinSelectionUpdateState(assetShortfalls) - const afterCount = (yield* Ref.get(ctx.state.selectedUtxos)).length - const addedCount = afterCount - beforeCount - - const updatedInputAssets = yield* Ref.get(ctx.state.totalInputAssets) - yield* Effect.logDebug( - `[SelectionV2] Added ${addedCount} UTxO(s), ` + - `Total selected: ${afterCount}, ` + - `New total lovelace: ${updatedInputAssets.lovelace || 0n}` - ) - } - - // Common path: increment attempt, clear shortfall (we've accounted for it), and proceed to change creation - yield* Ref.update(buildCtxRef, (ctx) => ({ ...ctx, attempt: ctx.attempt + 1, shortfall: 0n })) - return { next: "changeCreation" as const } - }) - - /** - * V2 Phase 2: Change Creation - * - * Creates change outputs using tentative leftover (inputs - outputs - fee). - * Each output created is valid (meets minUTxO). Balance phase verifies total sufficiency. - */ - const phaseChangeCreationV2 = Effect.gen(function* () { - const ctx = yield* TxContext - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) - - yield* Effect.logDebug(`[ChangeCreationV2] Fee from context: ${buildCtx.calculatedFee}`) - - // Calculate tentative leftover after fee - const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) - const outputAssets = yield* Ref.get(ctx.state.totalOutputAssets) - const leftoverBeforeFee = calculateLeftoverAssets(inputAssets, outputAssets) - - const tentativeLeftover: Assets.Assets = { - ...leftoverBeforeFee, - lovelace: leftoverBeforeFee.lovelace - buildCtx.calculatedFee - } - - // Early exit if negative - balance phase will trigger reselection - if (tentativeLeftover.lovelace < 0n) { - yield* Effect.logDebug( - `[ChangeCreationV2] Insufficient lovelace for fee: ${tentativeLeftover.lovelace}. ` + - `Skipping change, balance verification will trigger reselection.` - ) - - yield* Ref.update(buildCtxRef, (ctx) => ({ - ...ctx, - changeOutputs: [] - })) - return { next: "feeCalculation" as const } - } - - // Unfrack path: Create multiple outputs - if (ctx.options?.unfrack && buildCtx.canUnfrack) { - const changeOutputs = yield* createChangeOutputs(tentativeLeftover, ctx) - - yield* Effect.logDebug(`[ChangeCreationV2] Successfully created ${changeOutputs.length} unfracked outputs`) - - // Store outputs and proceed to fee calculation - yield* Ref.update(buildCtxRef, (ctx) => ({ - ...ctx, - changeOutputs - })) - return { next: "feeCalculation" as const } - } - - // Single output path: Validate minUTxO requirement - const minLovelace = yield* calculateMinimumUtxoLovelace({ - address: ctx.config.changeAddress, - assets: tentativeLeftover, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) - - if (tentativeLeftover.lovelace < minLovelace) { - const hasNativeAssets = Object.keys(tentativeLeftover).length > 1 - - if (hasNativeAssets) { - // Native assets MUST go in change output (ledger rule) - // Create placeholder with required minUTxO - balance will detect shortfall - yield* Effect.logDebug( - `[ChangeCreationV2] Native assets need ${minLovelace} lovelace, only have ${tentativeLeftover.lovelace}. ` + - `Creating placeholder change to trigger reselection.` - ) - - const changeOutputs = [ - { - address: ctx.config.changeAddress, - assets: { ...tentativeLeftover, lovelace: minLovelace } - } - ] - - yield* Ref.update(buildCtxRef, (ctx) => ({ - ...ctx, - changeOutputs - })) - return { next: "feeCalculation" as const } - } - - // Insufficient ADA-only leftover - return empty, balance will handle (drainTo/burn) - yield* Effect.logDebug( - `[ChangeCreationV2] Insufficient lovelace for change (${tentativeLeftover.lovelace} < ${minLovelace}), returning empty change` - ) - - yield* Ref.update(buildCtxRef, (ctx) => ({ ...ctx, changeOutputs: [] })) - return { next: "feeCalculation" as const } - } - - // Create valid single change output - const changeOutput = yield* makeTxOutput({ - address: ctx.config.changeAddress, - assets: tentativeLeftover - }) - - yield* Effect.logDebug(`[ChangeCreationV2] Created 1 change output with ${tentativeLeftover.lovelace} lovelace`) - - yield* Ref.update(buildCtxRef, (ctx) => ({ - ...ctx, - changeOutputs: [changeOutput] - })) - return { next: "feeCalculation" as const } - }) - - /** - * Helper: Create unfracked change outputs (multiple outputs) - * Only called when unfrack option is enabled. - * Returns change outputs array or undefined if unfrack is not viable. - * - * @param leftoverAfterFee - Tentative leftover calculated with previous fee estimate - * @param ctx - Transaction context data - * @returns ReadonlyArray or undefined if not viable - */ - const createChangeOutputs = ( - leftoverAfterFee: Assets.Assets, - ctx: TxContextData - ): Effect.Effect< - ReadonlyArray, - TransactionBuilderError - > => - Effect.gen(function* () { - // Empty leftover = no change needed - if (Assets.isEmpty(leftoverAfterFee)) { - return [] - } - - // Create unfracked outputs with proper minUTxO calculation - const unfrackOptions = ctx.options!.unfrack! // Safe: only called when unfrack is enabled - - const changeOutputs = yield* Unfrack.createUnfrackedChangeOutputs( - ctx.config.changeAddress, - leftoverAfterFee, - unfrackOptions, - ctx.config.protocolParameters.coinsPerUtxoByte - ).pipe( - Effect.mapError( - (err) => - new TransactionBuilderError({ - message: `Failed to create unfracked change outputs: ${err.message}`, - cause: err - }) - ) - ) - - yield* Effect.logDebug( - `[ChangeCreationV2] Created ${changeOutputs.length} unfracked change outputs` - ) - - return changeOutputs - }) - - /** - * V2 Phase 3: Fee Calculation - */ - const phaseFeeCalculationV2 = Effect.gen(function* () { - const ctx = yield* TxContext - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) - - const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) - const baseOutputs = yield* Ref.get(ctx.state.outputs) - const inputs = yield* buildTransactionInputs(selectedUtxos) - - yield* Effect.logDebug( - `[FeeCalculationV2] Starting fee calculation with ${baseOutputs.length} base outputs + ${buildCtx.changeOutputs.length} change outputs` - ) - - // Calculate fee WITH change outputs - const allOutputs = [...baseOutputs, ...buildCtx.changeOutputs] - const calculatedFee = yield* calculateFeeIteratively(selectedUtxos, inputs, allOutputs, { - minFeeCoefficient: ctx.config.protocolParameters.minFeeCoefficient, - minFeeConstant: ctx.config.protocolParameters.minFeeConstant - }) - - yield* Effect.logDebug(`[FeeCalculationV2] Calculated fee: ${calculatedFee}`) - - // Calculate leftover after fee NOW (after fee is known) - const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) - const outputAssets = yield* Ref.get(ctx.state.totalOutputAssets) - const leftoverBeforeFee = calculateLeftoverAssets(inputAssets, outputAssets) - - const leftoverAfterFee: Assets.Assets = { - ...leftoverBeforeFee, - lovelace: leftoverBeforeFee.lovelace - calculatedFee - } - - // Store both fee and leftoverAfterFee in context - yield* Ref.update(buildCtxRef, (ctx) => ({ - ...ctx, - calculatedFee, - leftoverAfterFee - })) - - return { next: "balanceVerification" as const } - }) - - /** - * V2 Phase 4: Balance Verification - Decides what to do next - */ - const phaseBalanceVerificationV2 = Effect.gen(function* () { - const ctx = yield* TxContext - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) - - yield* Effect.logDebug(`[BalanceVerificationV2] Starting balance verification (attempt ${buildCtx.attempt})`) - - const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) - const outputAssets = yield* Ref.get(ctx.state.totalOutputAssets) - - // Calculate total output (outputs + change + fee) - const changeTotal = buildCtx.changeOutputs.reduce( - (sum, output) => sum + Assets.getAsset(output.assets, "lovelace"), - 0n - ) - - const totalOut = outputAssets.lovelace + changeTotal + buildCtx.calculatedFee - const totalIn = inputAssets.lovelace - const difference = totalOut - totalIn - - yield* Effect.logDebug(`[BalanceVerificationV2] In: ${totalIn}, Out: ${totalOut}, Diff: ${difference}`) - - // Balanced! - if (difference === 0n) { - yield* Effect.logDebug("[BalanceVerificationV2] Transaction balanced!") - return { next: "complete" as const } - } - - // Not balanced - decide strategy - if (difference < 0n) { - // Too much leftover (excess) - const excessAmount = -difference - - // Get leftover assets after fee from context - const leftoverAssets = buildCtx.leftoverAfterFee - - // Calculate actual minUTxO requirement for these assets - const minUtxo = yield* calculateMinimumUtxoLovelace({ - address: ctx.config.changeAddress, - assets: leftoverAssets, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) - - yield* Effect.logDebug(`[BalanceVerificationV2] Excess: ${excessAmount}, MinUTxO: ${minUtxo} for leftover`) - - // If excess is less than minUTxO, can't create valid change output - // Use drainTo to merge into existing output - if (excessAmount < minUtxo && ctx.options?.drainTo !== undefined) { - // MANDATORY: drainTo ONLY works with ADA-only leftover - const hasNativeAssets = Object.keys(leftoverAssets).some((k) => k !== "lovelace") - if (hasNativeAssets) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `drainTo cannot be used with native assets in leftover. Use change output or unfrack instead.` - }) - ) - } - yield* Effect.logDebug(`[BalanceVerificationV2] Excess < minUTxO, using drainTo`) - return { next: "drainTo" as const } - } - - // If excess is too small and burn is explicitly allowed - if (excessAmount < minUtxo && ctx.options?.onInsufficientChange === "burn") { - yield* Effect.logDebug(`[BalanceVerificationV2] Excess < minUTxO, burning as fee`) - return { next: "burnAsFee" as const } - } - - // If excess is too small but no fallback configured, fail - if (excessAmount < minUtxo) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Change output insufficient: ${excessAmount} lovelace < ${minUtxo} minUTxO. ` + - `Configure drainTo to merge with an existing output, or set onInsufficientChange: 'burn' ` + - `to explicitly allow burning as extra fee.`, - cause: { excessAmount: excessAmount.toString(), minUtxo: minUtxo.toString() } - }) - ) - } - - // Otherwise recreate change with correct amount - yield* Effect.logDebug(`[BalanceVerificationV2] Excess >= minUTxO, recreating change`) - return { next: "changeCreation" as const } - } - - // Short on lovelace (difference > 0) - - // Strategy 1: Try reselection if attempts remaining - if (buildCtx.attempt < MAX_RESELECTION_ATTEMPTS) { - yield* Effect.logDebug(`[BalanceVerificationV2] Shortfall: ${difference}, triggering reselection`) - yield* Ref.update(buildCtxRef, (ctx) => ({ ...ctx, shortfall: difference })) - return { next: "selection" as const } - } - - // Attempts exhausted - try fallbacks - - // Strategy 2: DrainTo if configured - if (ctx.options?.drainTo !== undefined) { - // MANDATORY: drainTo ONLY works with ADA-only leftover - const leftoverAssets = buildCtx.leftoverAfterFee - const hasNativeAssets = Object.keys(leftoverAssets).some((k) => k !== "lovelace") - if (hasNativeAssets) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `drainTo cannot be used with native assets in leftover. Use change output or unfrack instead.` - }) - ) - } - yield* Effect.logDebug("[BalanceVerificationV2] Attempts exhausted, trying drainTo") - return { next: "drainTo" as const } - } - - // Strategy 3: Burn as fee (TODO: add allowBurnAsFee to BuildOptions) - // if (ctx.options?.allowBurnAsFee && difference < 10_000n) { - // yield* Effect.logDebug("[BalanceVerificationV2] Attempts exhausted, burning as fee") - // return { next: "burnAsFee" as const } - // } - - // No fallbacks available - return yield* Effect.fail( - new TransactionBuilderError({ - message: `Cannot balance transaction after ${buildCtx.attempt} attempts`, - cause: { shortfall: difference.toString(), noFallbacksAvailable: true } - }) - ) - }) - - /** - * V2 Phase 5: DrainTo Fallback - */ - const phaseDrainToV2 = Effect.gen(function* () { - const ctx = yield* TxContext - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) - - yield* Effect.logDebug(`[DrainToV2] Starting drainTo fallback (attempt ${buildCtx.attempt})`) - - const drainToIndex = ctx.options?.drainTo - if (drainToIndex === undefined) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: "drainTo index not configured" - }) - ) - } - const baseOutputs = yield* Ref.get(ctx.state.outputs) - const targetOutput = baseOutputs[drainToIndex] - - if (!targetOutput) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `drainTo index ${drainToIndex} out of bounds` - }) - ) - } - - // Get leftover after fee from context - const leftoverAfterFee = buildCtx.leftoverAfterFee - - // MANDATORY: drainTo ONLY works with ADA-only leftover - // Native assets would increase CBOR size -> fee increase -> potential imbalance - const hasNativeAssets = Object.keys(leftoverAfterFee).some((k) => k !== "lovelace") - if (hasNativeAssets) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: `drainTo cannot be used with native assets in leftover. Use change output or unfrack instead.` - }) - ) - } - - // Merge leftover into target output - const mergedAssets = Assets.add(targetOutput.assets, leftoverAfterFee) - - const mergedOutput: UTxO.TxOutput = { - ...targetOutput, - assets: mergedAssets - } - - // SAFETY CHECK: Validate merged output meets minUTxO requirement - // This is critical - the old implementation validates this 3 times! - const mergedMinUtxo = yield* calculateMinimumUtxoLovelace({ - address: mergedOutput.address, - assets: mergedAssets, - datum: mergedOutput.datumOption, - scriptRef: mergedOutput.scriptRef, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte - }) +// ============================================================================ - const mergedLovelace = Assets.getAsset(mergedAssets, "lovelace") +/** + * Construct a TransactionBuilder instance from protocol configuration. + * + * The builder accumulates chainable method calls as deferred ProgramSteps. Calling build() or chain() + * creates fresh state (new Refs) and executes all accumulated programs sequentially, ensuring + * no state pollution between invocations. + * + * Generic type parameter TResult determines what build() returns: + * - SignBuilder (default): When wallet has signing capability + * - TransactionResultBase: When wallet is read-only + * + * @typeParam TResult - The result type for build() methods (SignBuilder or TransactionResultBase) + * + * @since 2.0.0 + * @category constructors + */ +export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { + // Protocol parameters validation is deferred to build time when they are resolved + // (from BuildOptions > config > provider) - yield* Effect.logDebug( - `[DrainToV2] Merged output validation: ${mergedLovelace} lovelace ` + `(minUTxO: ${mergedMinUtxo})` - ) + // ProgramSteps array - stores deferred operations + // NO state created here - state is fresh per build() + const programs: Array = [] - if (mergedLovelace < mergedMinUtxo) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `drainTo validation failed: Merged output at index ${drainToIndex} ` + - `has ${mergedLovelace} lovelace but requires minimum ${mergedMinUtxo}. ` + - `The target output has too many assets to absorb the leftover. ` + - `Consider using a different drainTo target or adding more funds.`, - cause: { - drainToIndex, - mergedLovelace: mergedLovelace.toString(), - requiredMinUtxo: mergedMinUtxo.toString() - } + // Helper: Get coin selection algorithm function from name + const getCoinSelectionAlgorithm = (algorithm: CoinSelectionAlgorithm): CoinSelectionFunction => { + switch (algorithm) { + case "largest-first": + return largestFirstSelection + case "random-improve": + throw new TransactionBuilderError({ + message: "random-improve algorithm not yet implemented", + cause: { algorithm } + }) + case "optimal": + throw new TransactionBuilderError({ + message: "optimal algorithm not yet implemented", + cause: { algorithm } + }) + default: + throw new TransactionBuilderError({ + message: `Unknown coin selection algorithm: ${algorithm}`, + cause: { algorithm } }) - ) } + } - // Update outputs - yield* Ref.update(ctx.state.outputs, (outputs) => outputs.map((o, i) => (i === drainToIndex ? mergedOutput : o))) - - // Clear change outputs - yield* Ref.update(buildCtxRef, (ctx) => ({ ...ctx, changeOutputs: [] })) + // Helper: Create fresh state Effect + const createFreshState = () => + Effect.gen(function* () { + return { + selectedUtxos: yield* Ref.make>([]), // Array for ordering + outputs: yield* Ref.make>([]), + scripts: yield* Ref.make(new Map()), + totalOutputAssets: yield* Ref.make({ lovelace: 0n }), + totalInputAssets: yield* Ref.make({ lovelace: 0n }), + redeemers: yield* Ref.make(new Map()) + } + }) - yield* Effect.logDebug(`[DrainToV2] Successfully merged leftover (validated: ${mergedLovelace} >= ${mergedMinUtxo})`) - return { next: "complete" as const } - }) + /** + * Helper: Format assets for logging (BigInt-safe, truncates long unit names) + */ + const formatAssetsForLog = (assets: Assets.Assets): string => { + return Object.entries(assets) + .map(([unit, amount]) => `${unit.substring(0, 16)}...: ${amount.toString()}`) + .join(", ") + } /** - * V2 Phase 6: BurnAsFee Fallback + * Helper: Create unfracked change outputs (multiple outputs) + * Only called when unfrack option is enabled. + * Returns change outputs array or undefined if unfrack is not viable. + * + * @param leftoverAfterFee - Tentative leftover calculated with previous fee estimate + * @param ctx - Transaction context data + * @param changeAddress - Resolved change address for this build + * @returns ReadonlyArray or undefined if not viable */ - const phaseBurnAsFeeV2 = Effect.gen(function* () { - const buildCtxRef = yield* BuildContextTag - const buildCtx = yield* Ref.get(buildCtxRef) + const createChangeOutputs = ( + leftoverAfterFee: Assets.Assets, + ctx: TxContextData, + changeAddress: string, + coinsPerUtxoByte: bigint + ): Effect.Effect, TransactionBuilderError> => + Effect.gen(function* () { + // Empty leftover = no change needed + if (Assets.isEmpty(leftoverAfterFee)) { + return [] + } - // Get leftover after fee from context - const leftoverAfterFee = buildCtx.leftoverAfterFee + // Create unfracked outputs with proper minUTxO calculation + const unfrackOptions = ctx.options!.unfrack! // Safe: only called when unfrack is enabled - // SAFETY CHECK: Cannot burn leftover when native assets present - // Native assets can only be transferred to outputs (ledger rules enforce this) - // Attempting to burn without including them in outputs would result in a rejected transaction - const hasNativeAssets = Object.keys(leftoverAfterFee).some((k) => k !== "lovelace") - if (hasNativeAssets) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Cannot burn leftover as fee: Native assets present and must be transferred to outputs. ` + - `Leftover contains: ${JSON.stringify(leftoverAfterFee)}. ` + - `Transaction would be rejected by ledger. ` + - `Use change output, drainTo, or unfrack to preserve native assets.` - }) + const changeOutputs = yield* Unfrack.createUnfrackedChangeOutputs( + changeAddress, + leftoverAfterFee, + unfrackOptions, + coinsPerUtxoByte + ).pipe( + Effect.mapError( + (err) => + new TransactionBuilderError({ + message: `Failed to create unfracked change outputs: ${err.message}`, + cause: err + }) + ) ) - } - - yield* Effect.logDebug( - `[BurnAsFeeV2] Burning ${leftoverAfterFee.lovelace} lovelace as extra fee ` + `(below minUTxO, no native assets)` - ) - - // Just accept the higher fee, no adjustment needed - return { next: "complete" as const } - }) - // ============================================================================ - // END V2 IMPLEMENTATION - // ============================================================================ + yield* Effect.logDebug(`[ChangeCreationV2] Created ${changeOutputs.length} unfracked change outputs`) - /** - * Helper: Calculate leftover assets (inputs - outputs) - */ - const calculateLeftoverAssets = ( - inputAssets: Record, - outputAssets: Record - ): Assets.Assets => { - const leftover: Assets.Assets = { ...inputAssets, lovelace: inputAssets.lovelace || 0n } - for (const [unit, amount] of Object.entries(outputAssets)) { - const current = leftover[unit] || 0n - const remaining = current - (amount as bigint) - if (remaining > 0n) { - leftover[unit] = remaining - } else { - delete leftover[unit] - } - } - return leftover - } + return changeOutputs + }) // ============================================================================ // Coin Selection Helper Functions @@ -2735,7 +1137,9 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { const ctx = yield* TxContext const alreadySelected = yield* Ref.get(ctx.state.selectedUtxos) - const availableUtxos = getAvailableUtxos(ctx.config.availableUtxos, alreadySelected) + // Get resolved availableUtxos from context tag + const allAvailableUtxos = yield* AvailableUtxosTag + const availableUtxos = getAvailableUtxos(allAvailableUtxos, alreadySelected) const coinSelectionFn = resolveCoinSelectionFn(ctx.options?.coinSelection) const { selectedUtxos } = yield* Effect.try({ @@ -2753,23 +1157,19 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { }) // ============================================================================ - // End of State Machine V2 Implementation - // ============================================================================ - - // ============================================================================ - // V3 State Machine Implementation - Mathematical Validation Approach + // State Machine Implementation - Mathematical Validation Approach // ============================================================================ /** - * V3 Build phases + * Build phases */ - type V3Phase = "selection" | "changeCreation" | "feeCalculation" | "balance" | "fallback" | "complete" + type Phase = "selection" | "changeCreation" | "feeCalculation" | "balance" | "fallback" | "complete" /** - * V3 BuildContext - state machine context + * BuildContext - state machine context */ - interface V3BuildContext { - readonly phase: V3Phase + interface BuildContext { + readonly phase: Phase readonly attempt: number readonly calculatedFee: bigint readonly shortfall: bigint @@ -2778,25 +1178,15 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { readonly canUnfrack: boolean } - /** - * V3 BuildContext Tag for Effect Context - */ - const V3BuildContextTag = Context.GenericTag>("V3BuildContext") + const BuildContextTag = Context.GenericTag>("V3BuildContext") - /** - * V3 Phase result - */ - interface V3PhaseResult { - readonly next: V3Phase + interface PhaseResult { + readonly next: Phase } - // ============================================================================ - // V3 PHASE: Selection - // ============================================================================ - const phaseSelectionV3 = Effect.gen(function* () { const ctx = yield* TxContext - const buildCtxRef = yield* V3BuildContextTag + const buildCtxRef = yield* BuildContextTag const buildCtx = yield* Ref.get(buildCtxRef) const inputAssets = yield* Ref.get(ctx.state.totalInputAssets) @@ -2812,7 +1202,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { // Step 4: Calculate asset delta & extract shortfalls const assetDelta = Assets.subtract(totalNeeded, inputAssets) const assetShortfalls = Assets.filter(assetDelta, (_unit, amount) => amount > 0n) - + // During reselection (shortfall > 0), we need to select MORE lovelace // even if inputAssets >= totalNeeded, because the shortfall indicates // insufficient lovelace for change output minUTxO requirement @@ -2852,25 +1242,21 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { // Step 6: Update context and proceed yield* Ref.update(buildCtxRef, (ctx) => ({ ...ctx, attempt: ctx.attempt + 1, shortfall: 0n })) - return { next: "changeCreation" as V3Phase } + return { next: "changeCreation" as Phase } }) - // ============================================================================ - // V3 PHASE: Change Creation - // ============================================================================ - /** - * V3 Change Creation Phase - * + * Change Creation Phase + * * Creates change outputs from leftover assets using a cascading retry strategy. * Both unfrack (N outputs) and single output follow the same retry pattern: * try with available funds → if insufficient, reselect (up to MAX_ATTEMPTS) → fallback. - * + * * **Symmetric Retry Flow (Unfrack vs Single Output):** * ``` * UNFRACK (N outputs) SINGLE OUTPUT (1 output) * ───────────────────────────────────────────────────────────────── - * + * * Try: Create N outputs Try: Create 1 output * ↓ ↓ * Check: leftover >= (minUTxO × N)? Check: leftover >= minUTxO? @@ -2884,31 +1270,31 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { * ├─ (retry/fallback) ├─ burn (leftover → fee) * └─ ... └─ error * ``` - * + * * **Detailed Flow:** * ``` * 1. Calculate tentative leftover (inputs - outputs - contextFee) - * + * * 2. If unfrack enabled and canUnfrack=true: * → Try createUnfrackedChangeOutputs() (N outputs) * → Success: store N outputs, goto FeeCalculation * → Not affordable: * ├─ If attempt < MAX_ATTEMPTS: reselect (add more UTxOs) * └─ If attempt >= MAX_ATTEMPTS: canUnfrack=false, goto step 3 - * + * * 3. Single output approach: * → Create 1 change output with leftover * → Success: store 1 output, goto FeeCalculation * → Not affordable: * ├─ If attempt < MAX_ATTEMPTS: reselect (add more UTxOs) * └─ If attempt >= MAX_ATTEMPTS: goto step 4 - * + * * 4. Insufficient change fallbacks (single-output only): * a. If drainTo specified: merge into existing output * b. If onInsufficientChange="burn": leftover becomes fee * c. If onInsufficientChange="error": throw error * ``` - * + * * **Key Principles:** * - Unfrack and single output use SAME retry mechanism (reselection up to MAX_ATTEMPTS) * - Phase loop handles fee convergence (leftover recalculated each iteration) @@ -2916,13 +1302,14 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { * - canUnfrack flag prevents retry loops (once false, stays false) * - drainTo and burn are terminal fallbacks (single-output only) * - Unfrack outputs bypass drainTo/burn (they're already valid) - * + * * @returns Next phase to transition to */ const phaseChangeCreationV3 = Effect.gen(function* () { const ctx = yield* TxContext - const buildCtxRef = yield* V3BuildContextTag + const buildCtxRef = yield* BuildContextTag const buildCtx = yield* Ref.get(buildCtxRef) + const changeAddress = yield* ChangeAddressTag yield* Effect.logDebug(`[ChangeCreationV3] Fee from context: ${buildCtx.calculatedFee}`) @@ -2951,16 +1338,17 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { shortfall, changeOutputs: [] })) - return { next: "selection" as V3Phase } + return { next: "selection" as Phase } } // Step 4: Affordability check - verify minimum (single output) is affordable // This pre-flight check ensures we can create at least one valid change output // before attempting any unfrack strategies + const protocolParams = yield* ProtocolParametersTag const minLovelaceForSingle = yield* calculateMinimumUtxoLovelace({ - address: ctx.config.changeAddress, + address: changeAddress, assets: tentativeLeftover, - coinsPerUtxoByte: ctx.config.protocolParameters.coinsPerUtxoByte + coinsPerUtxoByte: protocolParams.coinsPerUtxoByte }) if (tentativeLeftover.lovelace < minLovelaceForSingle) { @@ -2974,7 +1362,8 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { // Check if we have available UTxOs for reselection const alreadySelected = yield* Ref.get(ctx.state.selectedUtxos) - const availableUtxos = getAvailableUtxos(ctx.config.availableUtxos, alreadySelected) + const allAvailableUtxos = yield* AvailableUtxosTag + const availableUtxos = getAvailableUtxos(allAvailableUtxos, alreadySelected) const hasMoreUtxos = availableUtxos.length > 0 // Try reselection up to MAX_ATTEMPTS (if UTxOs available) @@ -2991,7 +1380,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { changeOutputs: [] })) - return { next: "selection" as V3Phase } + return { next: "selection" as Phase } } // No more UTxOs OR MAX_ATTEMPTS exhausted - check fallback options @@ -3042,12 +1431,18 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { } // Fallback strategies configured - proceed to Fallback phase - return { next: "fallback" as V3Phase } + return { next: "fallback" as Phase } } // Step 5: Unfrack path (single output IS affordable, try bundles/subdivision) if (ctx.options?.unfrack && buildCtx.canUnfrack) { - const changeOutputs = yield* createChangeOutputs(tentativeLeftover, ctx) + const protocolParams = yield* ProtocolParametersTag + const changeOutputs = yield* createChangeOutputs( + tentativeLeftover, + ctx, + changeAddress, + protocolParams.coinsPerUtxoByte + ) yield* Effect.logDebug(`[ChangeCreationV3] Successfully created ${changeOutputs.length} unfracked outputs`) @@ -3056,13 +1451,13 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { ...ctx, changeOutputs })) - return { next: "feeCalculation" as V3Phase } + return { next: "feeCalculation" as Phase } } // Step 6: Single output path - create single change output // Affordability already verified in Step 4, so we can create output directly const singleOutput: UTxO.TxOutput = { - address: ctx.config.changeAddress, + address: changeAddress, assets: tentativeLeftover, datumOption: undefined, scriptRef: undefined @@ -3073,17 +1468,13 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { changeOutputs: [singleOutput] })) - return { next: "feeCalculation" as V3Phase } + return { next: "feeCalculation" as Phase } }) - // ============================================================================ - // V3 PHASE: Fee Calculation - // ============================================================================ - const phaseFeeCalculationV3 = Effect.gen(function* () { // Step 1: Get contexts and current state const ctx = yield* TxContext - const buildCtxRef = yield* V3BuildContextTag + const buildCtxRef = yield* BuildContextTag const buildCtx = yield* Ref.get(buildCtxRef) const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) @@ -3100,9 +1491,10 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { const allOutputs = [...baseOutputs, ...buildCtx.changeOutputs] // Step 4: Calculate fee WITH change outputs + const protocolParams = yield* ProtocolParametersTag const calculatedFee = yield* calculateFeeIteratively(selectedUtxos, inputs, allOutputs, { - minFeeCoefficient: ctx.config.protocolParameters.minFeeCoefficient, - minFeeConstant: ctx.config.protocolParameters.minFeeConstant + minFeeCoefficient: protocolParams.minFeeCoefficient, + minFeeConstant: protocolParams.minFeeConstant }) yield* Effect.logDebug(`[FeeCalculationV3] Calculated fee: ${calculatedFee}`) @@ -3124,17 +1516,13 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { leftoverAfterFee })) - return { next: "balance" as V3Phase } + return { next: "balance" as Phase } }) - // ============================================================================ - // V3 PHASE: Balance Verification - // ============================================================================ - const phaseBalanceV3 = Effect.gen(function* () { // Step 1: Get contexts and log start const ctx = yield* TxContext - const buildCtxRef = yield* V3BuildContextTag + const buildCtxRef = yield* BuildContextTag const buildCtx = yield* Ref.get(buildCtxRef) yield* Effect.logDebug(`[BalanceV3] Starting balance verification (attempt ${buildCtx.attempt})`) @@ -3168,7 +1556,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { // Step 3: Check if balanced (delta is empty) → complete if (isBalanced) { yield* Effect.logDebug("[BalanceV3] Transaction balanced!") - return { next: "complete" as V3Phase } + return { next: "complete" as Phase } } // Step 4: Not balanced - check for native assets in delta (shouldn't happen) @@ -3186,71 +1574,69 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { const deltaLovelace = delta.lovelace // Excess: inputs > outputs + change + fee - // This should NEVER happen with V3's Option B design, EXCEPT: + // This should NEVER happen with Option B design, EXCEPT: // - Burn strategy: Positive delta is the burned leftover (expected and correct!) // - ChangeCreation creates change = tentativeLeftover (when sufficient) // - ChangeCreation routes to selection (when insufficient, never returns empty) // - Balance should only see: delta = 0 (balanced) or delta < 0 (shortfall) or delta > 0 (burn mode) - + if (deltaLovelace > 0n) { // Check if this is expected from burn strategy const isBurnMode = ctx.options?.onInsufficientChange === "burn" && buildCtx.changeOutputs.length === 0 - + // Check if this is expected from drainTo strategy const isDrainToMode = ctx.options?.drainTo !== undefined && buildCtx.changeOutputs.length === 0 - + if (isDrainToMode) { // DrainTo mode: Merge positive delta (leftover after fee) into target output const drainToIndex = ctx.options.drainTo! const outputs = yield* Ref.get(ctx.state.outputs) - + // Validate drainTo index (should already be validated in Fallback, but double-check) if (drainToIndex < 0 || drainToIndex >= outputs.length) { return yield* Effect.fail( new TransactionBuilderError({ - message: `[V3 Balance] Invalid drainTo index: ${drainToIndex}. Must be between 0 and ${outputs.length - 1}`, + message: `Invalid drainTo index: ${drainToIndex}. Must be between 0 and ${outputs.length - 1}`, cause: { drainToIndex, outputCount: outputs.length } }) ) } - + // Merge delta into target output const targetOutput = outputs[drainToIndex] const newLovelace = targetOutput.assets.lovelace + deltaLovelace const newAssets = { ...targetOutput.assets, lovelace: newLovelace } const updatedOutput = { ...targetOutput, assets: newAssets } - + // Update outputs const newOutputs = [...outputs] newOutputs[drainToIndex] = updatedOutput yield* Ref.set(ctx.state.outputs, newOutputs) - + // Recalculate totalOutputAssets - const newTotalOutputAssets = newOutputs.reduce( - (acc, output) => Assets.merge(acc, output.assets), - { lovelace: 0n } as Assets.Assets - ) + const newTotalOutputAssets = newOutputs.reduce((acc, output) => Assets.merge(acc, output.assets), { + lovelace: 0n + } as Assets.Assets) yield* Ref.set(ctx.state.totalOutputAssets, newTotalOutputAssets) - + yield* Effect.logDebug( `[BalanceV3] DrainTo mode: Merged ${deltaLovelace} lovelace into output[${drainToIndex}]. ` + `New output value: ${newLovelace}. Transaction balanced.` ) - return { next: "complete" as V3Phase } + return { next: "complete" as Phase } } else if (isBurnMode) { // Burn mode: Positive delta is the burned leftover (becomes implicit fee) yield* Effect.logDebug( - `[BalanceV3] Burn mode: ${deltaLovelace} lovelace burned as implicit fee. ` + - `Transaction balanced.` + `[BalanceV3] Burn mode: ${deltaLovelace} lovelace burned as implicit fee. ` + `Transaction balanced.` ) - return { next: "complete" as V3Phase } + return { next: "complete" as Phase } } else { // Not burn mode or drainTo: This is a bug return yield* Effect.fail( new TransactionBuilderError({ message: - `[V3 Balance] CRITICAL BUG: Excess lovelace detected (${deltaLovelace}). ` + - `V3's Option B design should never produce positive delta. ` + + ` CRITICAL BUG: Excess lovelace detected (${deltaLovelace}). ` + + `s Option B design should never produce positive delta. ` + `This indicates incorrect change creation or fee calculation logic.`, cause: { delta: formatAssetsForLog(delta), @@ -3269,28 +1655,25 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { // Shortfall: inputs < outputs + change + fee // Return to changeCreation to recreate change with correct fee // If leftover < minLovelace, changeCreation will trigger selection - + yield* Effect.logDebug( `[BalanceV3] Shortfall detected: ${-deltaLovelace} lovelace. ` + `Returning to changeCreation to adjust change output.` ) - return { next: "changeCreation" as V3Phase } + return { next: "changeCreation" as Phase } }) - // ============================================================================ - // V3 PHASE: Fallback - // ============================================================================ // Handles insufficient change scenarios with drain or burn strategies. // This phase is reached after MAX_ATTEMPTS exhausted in ChangeCreation. // Only applies to ADA-only leftover (native assets cannot be drained/burned). // Note: ChangeCreation ensures only ADA-only cases reach this phase. const phaseFallbackV3 = Effect.gen(function* () { - yield* Effect.logDebug("[V3] Phase: Fallback") + yield* Effect.logDebug("Phase: Fallback") const ctx = yield* TxContext - const buildCtxRef = yield* V3BuildContextTag + const buildCtxRef = yield* BuildContextTag // Note: We don't merge the leftover here. Instead, we just clear change outputs // and let the balance phase handle the merge after fee calculation. // This avoids circular dependency: fee depends on outputs, but drain amount depends on fee. @@ -3330,7 +1713,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { ) // Go to fee calculation to recalculate without change outputs - return { next: "feeCalculation" as V3Phase } + return { next: "feeCalculation" as Phase } } // --------------------------------------------------------------- @@ -3350,7 +1733,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { ) // Go to fee calculation to recalculate fee for transaction without change outputs - return { next: "feeCalculation" as V3Phase } + return { next: "feeCalculation" as Phase } } // --------------------------------------------------------------- @@ -3371,19 +1754,65 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { ) }) - // ============================================================================ - // V3 Main Build Loop - // ============================================================================ - const buildEffectCoreV3 = (options?: BuildOptions) => Effect.gen(function* () { const _ctx = yield* TxContext - // 1. Execute all programs to populate state + // Resolve protocol parameters once at build start + // Priority: BuildOptions override > provider.Effect.getProtocolParameters() > error + const protocolParameters: ProtocolParameters = yield* options?.protocolParameters !== undefined + ? Effect.succeed(options.protocolParameters) + : _ctx.config.provider + ? Effect.map( + _ctx.config.provider.Effect.getProtocolParameters(), + (params): ProtocolParameters => ({ + minFeeCoefficient: BigInt(params.minFeeA), + minFeeConstant: BigInt(params.minFeeB), + coinsPerUtxoByte: params.coinsPerUtxoByte, + maxTxSize: params.maxTxSize + }) + ) + : Effect.fail( + new TransactionBuilderError({ + message: + "No protocol parameters provided. Either provide protocolParameters in BuildOptions or provider in config.", + cause: null + }) + ) + + // Resolve change address once at build start + // Priority: BuildOptions override > wallet.Effect.address() > error + const changeAddress: string = yield* options?.changeAddress + ? Effect.succeed(options.changeAddress) + : _ctx.config.wallet + ? _ctx.config.wallet.Effect.address() + : Effect.fail( + new TransactionBuilderError({ + message: + "No change address provided. Either provide wallet in config or changeAddress in build options.", + cause: null + }) + ) + + // Resolve available UTxOs once at build start + // Priority: BuildOptions override > provider.Effect.getUtxos(wallet.address) > error + const availableUtxos: ReadonlyArray = yield* options?.availableUtxos + ? Effect.succeed(options.availableUtxos) + : _ctx.config.wallet && _ctx.config.provider + ? Effect.flatMap(_ctx.config.wallet.Effect.address(), (addr) => _ctx.config.provider!.Effect.getUtxos(addr)) + : Effect.fail( + new TransactionBuilderError({ + message: + "No available UTxOs provided. Either provide wallet+provider in config or availableUtxos in build options.", + cause: null + }) + ) + + // No need to create resolvedConfig - we provide resolved values via context tags below + yield* Effect.all(programs, { concurrency: "unbounded" }) - // 2. Create initial V3 build context - const initialBuildCtx: V3BuildContext = { + const initialBuildCtx: BuildContext = { phase: "selection" as const, attempt: 0, calculatedFee: 0n, @@ -3395,8 +1824,9 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { const ctxRef = yield* Ref.make(initialBuildCtx) - // 3. Run V3 state machine - yield* Effect.gen(function* () { + // Run phase loop and transaction assembly with all services provided + const { buildCtx, selectedUtxos, transaction, txWithFakeWitnesses } = yield* Effect.gen(function* () { + // Phase loop while (true) { const buildCtx = yield* Ref.get(ctxRef) @@ -3406,7 +1836,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { } // Route to phase - let result: V3PhaseResult + let result: PhaseResult switch (buildCtx.phase) { case "selection": { @@ -3435,93 +1865,109 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { } default: - return yield* Effect.fail( - new TransactionBuilderError({ message: `V3: Unknown phase: ${buildCtx.phase}` }) - ) + return yield* Effect.fail(new TransactionBuilderError({ message: `Unknown phase: ${buildCtx.phase}` })) } // Update phase yield* Ref.update(ctxRef, (c) => ({ ...c, phase: result.next })) } - }).pipe(Effect.provideService(V3BuildContextTag, ctxRef)) - - // 4. Add change outputs to transaction and assemble - const buildCtx = yield* Ref.get(ctxRef) - const ctx = yield* TxContext - yield* Effect.logDebug(`[V3] Build complete - fee: ${buildCtx.calculatedFee}`) + // 4. Add change outputs to transaction and assemble + const buildCtx = yield* Ref.get(ctxRef) + const ctx = yield* TxContext - // Add change outputs to the transaction outputs - if (buildCtx.changeOutputs.length > 0) { - const currentOutputs = yield* Ref.get(ctx.state.outputs) - yield* Ref.set(ctx.state.outputs, [...currentOutputs, ...buildCtx.changeOutputs]) + yield* Effect.logDebug(`Build complete - fee: ${buildCtx.calculatedFee}`) - yield* Effect.logDebug(`[V3] Added ${buildCtx.changeOutputs.length} change output(s) to transaction`) - } + // Add change outputs to the transaction outputs + if (buildCtx.changeOutputs.length > 0) { + const currentOutputs = yield* Ref.get(ctx.state.outputs) + yield* Ref.set(ctx.state.outputs, [...currentOutputs, ...buildCtx.changeOutputs]) - // Get final inputs and outputs for transaction assembly - const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) - const allOutputs = yield* Ref.get(ctx.state.outputs) + yield* Effect.logDebug(`Added ${buildCtx.changeOutputs.length} change output(s) to transaction`) + } - yield* Effect.logDebug( - `[V3] Assembling transaction: ${selectedUtxos.length} inputs, ${allOutputs.length} outputs, fee: ${buildCtx.calculatedFee}` - ) + // Get final inputs and outputs for transaction assembly + const selectedUtxos = yield* Ref.get(ctx.state.selectedUtxos) + const allOutputs = yield* Ref.get(ctx.state.outputs) - // Build transaction inputs and assemble transaction body - const inputs = yield* buildTransactionInputs(selectedUtxos) - const transaction = yield* assembleTransaction(inputs, allOutputs, buildCtx.calculatedFee) + yield* Effect.logDebug( + `Assembling transaction: ${selectedUtxos.length} inputs, ${allOutputs.length} outputs, fee: ${buildCtx.calculatedFee}` + ) - // SAFETY CHECK: Validate transaction size against protocol limit - const fakeWitnessSet = yield* buildFakeWitnessSet(selectedUtxos) + // Build transaction inputs and assemble transaction body + const inputs = yield* buildTransactionInputs(selectedUtxos) + const transaction = yield* assembleTransaction(inputs, allOutputs, buildCtx.calculatedFee) - const txWithFakeWitnesses = new Transaction.Transaction({ - body: transaction.body, - witnessSet: fakeWitnessSet, - isValid: true, - auxiliaryData: null - }) + // SAFETY CHECK: Validate transaction size against protocol limit + const fakeWitnessSet = yield* buildFakeWitnessSet(selectedUtxos) - const txSizeWithWitnesses = yield* calculateTransactionSize(txWithFakeWitnesses) + const txWithFakeWitnesses = new Transaction.Transaction({ + body: transaction.body, + witnessSet: fakeWitnessSet, + isValid: true, + auxiliaryData: null + }) - yield* Effect.logDebug( - `[V3] Transaction size: ${txSizeWithWitnesses} bytes ` + - `(with ${fakeWitnessSet.vkeyWitnesses?.length ?? 0} fake witnesses), ` + - `max=${ctx.config.protocolParameters.maxTxSize} bytes` - ) + const txSizeWithWitnesses = yield* calculateTransactionSize(txWithFakeWitnesses) + const protocolParams = yield* ProtocolParametersTag - if (txSizeWithWitnesses > ctx.config.protocolParameters.maxTxSize) { - return yield* Effect.fail( - new TransactionBuilderError({ - message: - `Transaction size (${txSizeWithWitnesses} bytes) exceeds protocol maximum (${ctx.config.protocolParameters.maxTxSize} bytes). ` + - `Consider splitting into multiple transactions.` - }) + yield* Effect.logDebug( + `Transaction size: ${txSizeWithWitnesses} bytes ` + + `(with ${fakeWitnessSet.vkeyWitnesses?.length ?? 0} fake witnesses), ` + + `max=${protocolParams.maxTxSize} bytes` ) - } - // Return SignBuilder with the assembled transaction - const signBuilder: SignBuilder = { - Effect: { - sign: () => Effect.fail(new TransactionBuilderError({ message: "[V3] Signing not yet implemented" })), - signWithWitness: () => - Effect.fail(new TransactionBuilderError({ message: "[V3] Witness signing not yet implemented" })), - assemble: () => Effect.fail(new TransactionBuilderError({ message: "[V3] Assemble not yet implemented" })), - partialSign: () => - Effect.fail(new TransactionBuilderError({ message: "[V3] Partial signing not yet implemented" })), - getWitnessSet: () => Effect.succeed(transaction.witnessSet), - toTransaction: () => Effect.succeed(transaction), - toTransactionWithFakeWitnesses: () => Effect.succeed(txWithFakeWitnesses) - }, - sign: () => Promise.reject(new Error("[V3] Signing not yet implemented")), - signWithWitness: () => Promise.reject(new Error("[V3] Witness signing not yet implemented")), - assemble: () => Promise.reject(new Error("[V3] Assemble not yet implemented")), - partialSign: () => Promise.reject(new Error("[V3] Partial signing not yet implemented")), - getWitnessSet: () => Promise.resolve(transaction.witnessSet), - toTransaction: () => Promise.resolve(transaction), - toTransactionWithFakeWitnesses: () => Promise.resolve(txWithFakeWitnesses) - } + if (txSizeWithWitnesses > protocolParams.maxTxSize) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + `Transaction size (${txSizeWithWitnesses} bytes) exceeds protocol maximum (${protocolParams.maxTxSize} bytes). ` + + `Consider splitting into multiple transactions.` + }) + ) + } - return signBuilder + // Return data for final result assembly + return { + transaction, + txWithFakeWitnesses, + buildCtx, + selectedUtxos + } + }).pipe( + Effect.provideService(BuildContextTag, ctxRef), + Effect.provideService(ProtocolParametersTag, protocolParameters), + Effect.provideService(ChangeAddressTag, changeAddress), + Effect.provideService(AvailableUtxosTag, availableUtxos) + ) + + // Assemble final result based on wallet capabilities + const ctx = yield* TxContext + const wallet = ctx.config.wallet + + // Type guard: Check if wallet is signing-capable (has signTx method) + const isSigningWallet = wallet && "signTx" in wallet + + if (isSigningWallet) { + // Return SignBuilder for signing-capable wallets + const signBuilder = makeSignBuilder({ + transaction, + transactionWithFakeWitnesses: txWithFakeWitnesses, + fee: buildCtx.calculatedFee, + utxos: selectedUtxos, + provider: ctx.config.provider!, + wallet: wallet as WalletNew.SigningWallet | WalletNew.ApiWallet + }) + return signBuilder + } else { + // Return TransactionResultBase for read-only wallets + const transactionResult = makeTransactionResult({ + transaction, + transactionWithFakeWitnesses: txWithFakeWitnesses, + fee: buildCtx.calculatedFee + }) + return transactionResult + } }).pipe( Effect.provideServiceEffect( TxContext, @@ -3535,10 +1981,6 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { ) ) - // ============================================================================ - // End of V3 State Machine Implementation - // ============================================================================ - // Core Effect logic for chaining const chainEffectCore = (options?: BuildOptions) => Effect.gen(function* () { @@ -3592,7 +2034,7 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { ) ) - const txBuilder: TransactionBuilder = { + const txBuilder: TransactionBuilder = { // ============================================================================ // Chainable builder methods - Create ProgramSteps, return same instance // ============================================================================ @@ -3616,43 +2058,29 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { // ============================================================================ buildEffect: (options?: BuildOptions) => { - if (options?.useV3) { - return buildEffectCoreV3(options) - } - const buildFn = options?.useStateMachine ? buildEffectCoreStateMachineV2 : buildEffectCore - return buildFn(options) + return buildEffectCoreV3(options) as unknown as Effect.Effect< + TResult, + TransactionBuilderError | EvaluationError | WalletNew.WalletError | Provider.ProviderError, + unknown + > }, build: (options?: BuildOptions) => { - if (options?.useV3) { - return Effect.runPromise( - buildEffectCoreV3(options).pipe( - Effect.provide(Layer.merge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug))) - ) - ) - } - const buildFn = options?.useStateMachine ? buildEffectCoreStateMachineV2 : buildEffectCore return Effect.runPromise( - buildFn(options).pipe(Effect.provide(Layer.merge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug)))) - ) + buildEffectCoreV3(options).pipe( + Effect.provide(Layer.merge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug))) + ) + ) as unknown as Promise }, - buildEither: (options?: BuildOptions) => { - if (options?.useV3) { - return Effect.runPromise( - buildEffectCoreV3(options).pipe( - Effect.either, - Effect.provide(Layer.merge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug))) - ) - ) - } - const buildFn = options?.useStateMachine ? buildEffectCoreStateMachineV2 : buildEffectCore return Effect.runPromise( - buildFn(options).pipe( + buildEffectCoreV3(options).pipe( Effect.either, Effect.provide(Layer.merge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug))) ) - ) + ) as unknown as Promise< + Either + > }, // ============================================================================ @@ -3676,9 +2104,3 @@ export const makeTxBuilder = (config: TxBuilderConfig): TransactionBuilder => { return txBuilder } - -// ============================================================================ -// Helper Functions - To be implemented -// ============================================================================ - -// Implementation functions are imported from TxBuilderImpl.js at top of file diff --git a/packages/evolution/src/sdk/builders/TransactionResult.ts b/packages/evolution/src/sdk/builders/TransactionResult.ts new file mode 100644 index 00000000..0f4df25f --- /dev/null +++ b/packages/evolution/src/sdk/builders/TransactionResult.ts @@ -0,0 +1,160 @@ +/** + * TransactionResult - Base interface for transaction building results + * + * Provides core functionality available to all transaction builders regardless + * of signing capability. This enables type-safe differentiation between + * read-only clients (can build but not sign) and signing clients (can build and sign). + * + * @since 2.0.0 + * @category builders + */ + +import { Effect } from "effect" + +import type * as Transaction from "../../core/Transaction.js" +import type { TransactionBuilderError } from "./TransactionBuilder.js" + +/** + * Base result interface for built transactions. + * + * Available on all transaction builders regardless of signing capability. + * Provides access to the unsigned transaction, fee estimates, and transaction + * with fake witnesses for size validation. + * + * @since 2.0.0 + * @category interfaces + */ +export interface TransactionResultBase { + /** + * Get the unsigned transaction. + * + * This transaction has a complete body but no witness set (signatures). + * Can be serialized to CBOR for external signing (hardware wallets, browser extensions, etc.) + * + * @returns Promise resolving to the unsigned transaction + * + * @example + * ```typescript + * const result = await readOnlyClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5_000_000n }) + * .build() + * + * const unsignedTx = await result.toTransaction() + * const txCbor = Transaction.toCBORHex(unsignedTx) + * // Export for external signing + * ``` + * + * @since 2.0.0 + * @category accessors + */ + readonly toTransaction: () => Promise + + /** + * Get the transaction with fake witnesses for fee validation. + * + * This transaction includes fake witness sets (294 bytes each) to accurately + * calculate the final transaction size and fees. Useful for validating that + * the calculated fee is sufficient for the final signed transaction. + * + * @returns Promise resolving to the transaction with fake witnesses + * + * @since 2.0.0 + * @category accessors + */ + readonly toTransactionWithFakeWitnesses: () => Promise + + /** + * Get the calculated transaction fee in lovelace. + * + * This is the fee that was calculated during the build process based on + * the transaction size (including fake witnesses) and protocol parameters. + * + * @returns Promise resolving to the transaction fee in lovelace + * + * @example + * ```typescript + * const result = await client.newTx() + * .payToAddress({ address: "addr...", lovelace: 5_000_000n }) + * .build() + * + * const fee = await result.estimateFee() + * console.log(`Transaction fee: ${fee} lovelace`) + * ``` + * + * @since 2.0.0 + * @category accessors + */ + readonly estimateFee: () => Promise + + /** + * Effect-based API for compositional workflows. + * + * Provides the same functionality as the Promise-based methods but returns + * Effect values for use in Effect-TS workflows with proper error handling + * and composition. + * + * @since 2.0.0 + * @category effects + */ + readonly Effect: { + /** + * Get the unsigned transaction as an Effect. + * + * @since 2.0.0 + */ + readonly toTransaction: () => Effect.Effect + + /** + * Get the transaction with fake witnesses as an Effect. + * + * @since 2.0.0 + */ + readonly toTransactionWithFakeWitnesses: () => Effect.Effect + + /** + * Get the calculated fee as an Effect. + * + * @since 2.0.0 + */ + readonly estimateFee: () => Effect.Effect + } +} + +// ============================================================================ +// TransactionResultBase Factory +// ============================================================================ + +/** + * Create a TransactionResultBase instance for a built transaction without signing capability. + * + * Used by ReadOnlyClient which can build transactions but cannot sign them. + * Provides access to the unsigned transaction, fake-witness transaction for fee validation, + * and fee estimation. + * + * @param transaction - The unsigned transaction + * @param transactionWithFakeWitnesses - The transaction with fake witnesses for size validation + * @param fee - The calculated transaction fee in lovelace + * + * @since 2.0.0 + * @category constructors + */ +export const makeTransactionResult = (params: { + transaction: Transaction.Transaction + transactionWithFakeWitnesses: Transaction.Transaction + fee: bigint +}): TransactionResultBase => { + const { fee, transaction, transactionWithFakeWitnesses } = params + + const resultEffect: TransactionResultBase["Effect"] = { + toTransaction: () => Effect.succeed(transaction), + toTransactionWithFakeWitnesses: () => Effect.succeed(transactionWithFakeWitnesses), + estimateFee: () => Effect.succeed(fee) + } + + return { + toTransaction: () => Promise.resolve(transaction), + toTransactionWithFakeWitnesses: () => Promise.resolve(transactionWithFakeWitnesses), + estimateFee: () => Promise.resolve(fee), + Effect: resultEffect + } +} diff --git a/packages/evolution/src/sdk/builders/index.ts b/packages/evolution/src/sdk/builders/index.ts index 7aaf40db..b31d903e 100644 --- a/packages/evolution/src/sdk/builders/index.ts +++ b/packages/evolution/src/sdk/builders/index.ts @@ -1,4 +1,8 @@ export * from "./CoinSelection.js" export * from "./operations/index.js" export * from "./SignBuilder.js" -export * from "./TransactionBuilder.js" \ No newline at end of file +export * from "./SignBuilderImpl.js" +export * from "./SubmitBuilder.js" +export * from "./SubmitBuilderImpl.js" +export * from "./TransactionBuilder.js" +export * from "./TransactionResult.js" \ No newline at end of file diff --git a/packages/evolution/src/sdk/client/Client.ts b/packages/evolution/src/sdk/client/Client.ts index 0a1399b3..6361aaeb 100644 --- a/packages/evolution/src/sdk/client/Client.ts +++ b/packages/evolution/src/sdk/client/Client.ts @@ -3,6 +3,8 @@ import { Data, type Effect, type Schedule } from "effect" +import type { TransactionBuilder } from "../builders/TransactionBuilder.js" +import type { TransactionResultBase } from "../builders/TransactionResult.js" import type * as Delegation from "../Delegation.js" import type * as Provider from "../provider/Provider.js" import type { EffectToPromiseAPI } from "../Type.js" @@ -73,17 +75,21 @@ export interface MinimalClient { config: T ) => T extends SeedWalletConfig ? SigningWalletClient - : T extends ApiWalletConfig - ? ApiWalletClient - : ReadOnlyWalletClient + : T extends PrivateKeyWalletConfig + ? SigningWalletClient + : T extends ApiWalletConfig + ? ApiWalletClient + : ReadOnlyWalletClient readonly attach: ( providerConfig: ProviderConfig, walletConfig: TW ) => TW extends SeedWalletConfig ? SigningClient - : TW extends ApiWalletConfig + : TW extends PrivateKeyWalletConfig ? SigningClient - : ReadOnlyClient + : TW extends ApiWalletConfig + ? SigningClient + : ReadOnlyClient // Effect namespace for methods with side effects only readonly Effect: MinimalClientEffect } @@ -97,27 +103,100 @@ export type ProviderOnlyClient = EffectToPromiseAPI & { config: T ) => T extends SeedWalletConfig ? SigningClient - : T extends ApiWalletConfig + : T extends PrivateKeyWalletConfig ? SigningClient - : ReadOnlyClient + : T extends ApiWalletConfig + ? SigningClient + : ReadOnlyClient // Effect namespace - includes all provider methods as Effects readonly Effect: Provider.ProviderEffect } /** * ReadOnlyClient - can query blockchain + wallet address operations + * + * ReadOnlyClient cannot sign transactions, so newTx() returns a TransactionBuilder + * that yields TransactionResultBase (unsigned transaction only). */ export type ReadOnlyClient = EffectToPromiseAPI & { - readonly newTx: (utxos?: ReadonlyArray) => any // TODO: Change to ReadOnlyTransactionBuilder when implementing tx builder + /** + * Create a new transaction builder for read-only operations. + * + * Returns a TransactionBuilder that builds unsigned transactions. + * The build() methods return TransactionResultBase which provides: + * - `.toTransaction()` - Get the unsigned transaction + * - `.toTransactionWithFakeWitnesses()` - Get transaction with fake witnesses for fee validation + * - `.estimateFee()` - Get the calculated fee + * + * @param utxos - Optional UTxOs to use for coin selection. If not provided, wallet UTxOs will be fetched automatically when build() is called. + * @returns A new TransactionBuilder instance configured with cached protocol parameters and wallet change address. + * + * @example + * ```typescript + * // Build unsigned transaction + * const result = await readOnlyClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5000000n }) + * .build() + * + * // Get unsigned transaction for external signing + * const unsignedTx = await result.toTransaction() + * const txCbor = Transaction.toCBORHex(unsignedTx) + * + * // Get fee estimate + * const fee = await result.estimateFee() + * ``` + * + * @since 2.0.0 + * @category transaction-building + */ + readonly newTx: (utxos?: ReadonlyArray) => TransactionBuilder // Effect namespace - includes all provider + wallet methods as Effects readonly Effect: ReadOnlyClientEffect } /** * SigningClient - full functionality: query blockchain + sign + submit + * + * SigningClient has wallet signing capability, so newTx() returns a TransactionBuilder + * that yields SignBuilder (can sign and submit transactions). */ export type SigningClient = EffectToPromiseAPI & { - readonly newTx: (utxos?: ReadonlyArray) => any // TODO: Change to ReadOnlyTransactionBuilder when implementing tx builder + /** + * Create a new transaction builder with signing capability. + * + * Returns a TransactionBuilder that can build and sign transactions. + * The build() methods return SignBuilder which provides: + * - `.sign()` - Sign and prepare for submission + * - `.toTransaction()` - Get the unsigned transaction + * - `.toTransactionWithFakeWitnesses()` - Get transaction with fake witnesses for fee validation + * - `.estimateFee()` - Get the calculated fee + * - `.partialSign()` - Create partial signature for multi-sig + * - `.assemble()` - Combine multiple signatures + * + * UTxOs for coin selection are fetched automatically from the wallet when build() is called. + * You can override UTxOs per-build using BuildOptions.availableUtxos. + * + * @returns A new TransactionBuilder instance configured with cached protocol parameters and wallet change address. + * + * @example + * ```typescript + * // Build and sign transaction + * const signBuilder = await signingClient.newTx() + * .payToAddress({ address: "addr...", lovelace: 5000000n }) + * .build() + * + * // Sign and submit + * const submitBuilder = await signBuilder.sign() + * const txHash = await submitBuilder.submit() + * + * // Or get unsigned transaction + * const unsignedTx = await signBuilder.toTransaction() + * ``` + * + * @since 2.0.0 + * @category transaction-building + */ + readonly newTx: () => TransactionBuilder // Effect namespace - includes all provider + wallet methods as Effects readonly Effect: SigningClientEffect } @@ -241,6 +320,13 @@ export interface SeedWalletConfig { readonly password?: string } +export interface PrivateKeyWalletConfig { + readonly type: "private-key" + readonly paymentKey: string // bech32 ed25519e_sk + readonly stakeKey?: string // bech32 ed25519e_sk (optional, for Base addresses) + readonly addressType?: "Base" | "Enterprise" +} + export interface ReadOnlyWalletConfig { readonly type: "read-only" readonly address: string @@ -252,5 +338,5 @@ export interface ApiWalletConfig { readonly api: WalletApi // CIP-30 wallet API interface } -export type WalletConfig = SeedWalletConfig | ReadOnlyWalletConfig | ApiWalletConfig +export type WalletConfig = SeedWalletConfig | PrivateKeyWalletConfig | ReadOnlyWalletConfig | ApiWalletConfig diff --git a/packages/evolution/src/sdk/client/ClientImpl.ts b/packages/evolution/src/sdk/client/ClientImpl.ts index 80d6b19a..a8d035a8 100644 --- a/packages/evolution/src/sdk/client/ClientImpl.ts +++ b/packages/evolution/src/sdk/client/ClientImpl.ts @@ -1,6 +1,6 @@ // ClientImpl.ts - Step-by-step implementation starting with MinimalClient -import { Effect, Either } from "effect" +import { Effect } from "effect" import * as KeyHash from "../../core/KeyHash.js" import * as PrivateKey from "../../core/PrivateKey.js" @@ -9,8 +9,12 @@ import * as Transaction from "../../core/Transaction.js" import * as TransactionHash from "../../core/TransactionHash.js" import * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js" import * as VKey from "../../core/VKey.js" +import { runEffect } from "../../utils/effect-runtime.js" import { hashTransaction } from "../../utils/Hash.js" import type * as Address from "../Address.js" +import type { SignBuilder } from "../builders/SignBuilder.js" +import { makeTxBuilder, type TransactionBuilder } from "../builders/TransactionBuilder.js" +import type { TransactionResultBase } from "../builders/TransactionResult.js" import * as Blockfrost from "../provider/Blockfrost.js" import * as Koios from "../provider/Koios.js" import * as Kupmios from "../provider/Kupmios.js" @@ -26,6 +30,7 @@ import { type MinimalClient, type MinimalClientEffect, type NetworkId, + type PrivateKeyWalletConfig, type ProviderConfig, type ProviderOnlyClient, type ReadOnlyClient, @@ -76,6 +81,8 @@ const normalizeNetworkId = (network: NetworkId): number => { } } +/** + * Convert SDK ProtocolParameters to TransactionBuilder format. /** * Map NetworkId discriminant to wallet network enumeration. * @@ -115,14 +122,14 @@ const createReadOnlyWallet = ( ): WalletNew.ReadOnlyWallet => { // Effect interface - methods that return Effects const walletEffect: WalletNew.ReadOnlyWalletEffect = { - address: Effect.succeed(address), - rewardAddress: Effect.succeed(rewardAddress ?? null) + address: () => Effect.succeed(address), + rewardAddress: () => Effect.succeed(rewardAddress ?? null) } return { - // Promise-based API - these are Promises, not functions - address: Promise.resolve(address), - rewardAddress: Promise.resolve(rewardAddress ?? null), + // Promise-based API - these are functions returning Promises + address: () => Promise.resolve(address), + rewardAddress: () => Promise.resolve(rewardAddress ?? null), // Effect namespace Effect: walletEffect, type: "read-only" @@ -188,9 +195,15 @@ const createReadOnlyClient = ( if (!rewardAddr) throw new Error("No reward address configured") return provider.getDelegation(rewardAddr) }, - // Transaction builder (TODO: implement later) - newTx: (_utxos?: any): any => { - throw new Error("newTx not yet implemented") + // Transaction builder - creates a new builder instance + newTx: (): TransactionBuilder => { + // ReadOnlyWallet provides change address and UTxO fetching via wallet.Effect.address() + // The wallet is passed to the builder config, which handles address and UTxO resolution automatically + // Protocol parameters are auto-fetched from provider during build() + return makeTxBuilder({ + wallet, + provider + }) }, // Effect namespace - combined provider + wallet Effects Effect: { @@ -282,50 +295,38 @@ const computeRequiredKeyHashesSync = (params: { } /** - * Construct a SigningWallet from mnemonic seed phrase by deriving keys and building a keystore. - * - * Derives payment and optional stake keys from the seed using the specified address type and account index. - * Returns a wallet with signing capability via both Promise and Effect APIs. + * Create a signing wallet from a seed phrase. * - * @since 2.0.0 + * Wallet creation is synchronous - sodium initialization and key derivation + * happen lazily on first crypto operation (signTx, signMessage). + * * @category constructors */ const createSigningWallet = (network: WalletNew.Network, config: SeedWalletConfig): WalletNew.SigningWallet => { - // Derive keys and address from seed - const derivation = Derivation.walletFromSeed(config.mnemonic, { + const derivationEffect = Derivation.walletFromSeed(config.mnemonic, { addressType: config.addressType ?? "Base", accountIndex: config.accountIndex ?? 0, password: config.password, network - }).pipe(Either.getOrThrow) - - // Build keystore: map KeyHash hex -> PrivateKey - const keyStore = new Map() - const paymentSk = PrivateKey.fromBech32(derivation.paymentKey) - const paymentKh = KeyHash.fromPrivateKey(paymentSk) - const paymentKhHex = KeyHash.toHex(paymentKh) - keyStore.set(paymentKhHex, paymentSk) - - let stakeSk: PrivateKey.PrivateKey | undefined - let stakeKhHex: string | undefined - if (derivation.stakeKey) { - stakeSk = PrivateKey.fromBech32(derivation.stakeKey) - const stakeKh = KeyHash.fromPrivateKey(stakeSk) - stakeKhHex = KeyHash.toHex(stakeKh) - keyStore.set(stakeKhHex, stakeSk) - } + }).pipe( + Effect.mapError( + (cause) => new WalletNew.WalletError({ message: cause.message, cause }) + ) + ) // Effect implementations are the source of truth const effectInterface: WalletNew.SigningWalletEffect = { - address: Effect.succeed(derivation.address), - rewardAddress: Effect.succeed(derivation.rewardAddress ?? null), + address: () => Effect.map(derivationEffect, (d) => d.address), + rewardAddress: () => Effect.map(derivationEffect, (d) => d.rewardAddress ?? null), signTx: (txOrHex: Transaction.Transaction | string, context?: { utxos?: ReadonlyArray }) => Effect.gen(function* () { + const derivation = yield* derivationEffect + const tx = typeof txOrHex === "string" ? yield* Transaction.Either.fromCBORHex(txOrHex).pipe( Effect.mapError( - (cause) => new WalletNew.WalletError({ message: "Failed to decode transaction", cause }) + (cause) => new WalletNew.WalletError({ message: cause.message, cause }) ) ) : txOrHex @@ -333,9 +334,9 @@ const createSigningWallet = (network: WalletNew.Network, config: SeedWalletConfi // Determine required key hashes for signing const required = computeRequiredKeyHashesSync({ - paymentKhHex, + paymentKhHex: derivation.paymentKhHex, rewardAddress: derivation.rewardAddress ?? null, - stakeKhHex, + stakeKhHex: derivation.stakeKhHex, tx, utxos }) @@ -347,7 +348,7 @@ const createSigningWallet = (network: WalletNew.Network, config: SeedWalletConfi const witnesses: Array = [] const seenVKeys = new Set() for (const khHex of required) { - const sk = keyStore.get(khHex) + const sk = derivation.keyStore.get(khHex) if (!sk) continue const sig = PrivateKey.sign(sk, msg) const vk = VKey.fromPrivateKey(sk) @@ -360,8 +361,9 @@ const createSigningWallet = (network: WalletNew.Network, config: SeedWalletConfi return witnesses.length > 0 ? TransactionWitnessSet.fromVKeyWitnesses(witnesses) : TransactionWitnessSet.empty() }), signMessage: (_address: Address.Address | RewardAddress.RewardAddress, payload: WalletNew.Payload) => - Effect.sync(() => { + Effect.map(derivationEffect, (derivation) => { // For now, always use payment key for message signing + const paymentSk = PrivateKey.fromBech32(derivation.paymentKey) const vk = VKey.fromPrivateKey(paymentSk) const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : payload const _sig = PrivateKey.sign(paymentSk, bytes) @@ -373,14 +375,101 @@ const createSigningWallet = (network: WalletNew.Network, config: SeedWalletConfi // Promise API runs the Effect implementations return { type: "signing", - address: Effect.runPromise(effectInterface.address), - rewardAddress: Effect.runPromise(effectInterface.rewardAddress), + address: () => Effect.runPromise(effectInterface.address()), + rewardAddress: () => Effect.runPromise(effectInterface.rewardAddress()), signTx: (txOrHex, context) => Effect.runPromise(effectInterface.signTx(txOrHex, context)), signMessage: (address, payload) => Effect.runPromise(effectInterface.signMessage(address, payload)), Effect: effectInterface } } +/** + * Create a signing wallet from private keys. + * + * @category constructors + */ +const createPrivateKeyWallet = ( + network: WalletNew.Network, + config: PrivateKeyWalletConfig +): WalletNew.SigningWallet => { + // walletFromPrivateKey now returns an Effect directly + const derivationEffect = Derivation.walletFromPrivateKey(config.paymentKey, { + stakeKeyBech32: config.stakeKey, + addressType: config.addressType ?? (config.stakeKey ? "Base" : "Enterprise"), + network + }).pipe( + Effect.mapError((cause) => new WalletNew.WalletError({ message: cause.message, cause })) + ) + + // Effect implementations are the source of truth + const effectInterface: WalletNew.SigningWalletEffect = { + address: () => Effect.map(derivationEffect, (d) => d.address), + rewardAddress: () => Effect.map(derivationEffect, (d) => d.rewardAddress ?? null), + signTx: (txOrHex: Transaction.Transaction | string, context?: { utxos?: ReadonlyArray }) => + Effect.gen(function* () { + const derivation = yield* derivationEffect + + const tx = + typeof txOrHex === "string" + ? yield* Transaction.Either.fromCBORHex(txOrHex).pipe( + Effect.mapError( + (cause) => new WalletNew.WalletError({ message: cause.message, cause }) + ) + ) + : txOrHex + const utxos = context?.utxos ?? [] + + // Determine required key hashes for signing + const required = computeRequiredKeyHashesSync({ + paymentKhHex: derivation.paymentKhHex, + rewardAddress: derivation.rewardAddress ?? null, + stakeKhHex: derivation.stakeKhHex, + tx, + utxos + }) + + // Build witnesses for keys we have + const txHash = hashTransaction(tx.body) + const msg = txHash.hash + + const witnesses: Array = [] + const seenVKeys = new Set() + for (const khHex of required) { + const sk = derivation.keyStore.get(khHex) + if (!sk) continue + const sig = PrivateKey.sign(sk, msg) + const vk = VKey.fromPrivateKey(sk) + const vkHex = VKey.toHex(vk) + if (seenVKeys.has(vkHex)) continue + seenVKeys.add(vkHex) + witnesses.push(new TransactionWitnessSet.VKeyWitness({ vkey: vk, signature: sig })) + } + + return witnesses.length > 0 ? TransactionWitnessSet.fromVKeyWitnesses(witnesses) : TransactionWitnessSet.empty() + }), + signMessage: (_address: Address.Address | RewardAddress.RewardAddress, payload: WalletNew.Payload) => + Effect.map(derivationEffect, (derivation) => { + // For now, always use payment key for message signing + const paymentSk = PrivateKey.fromBech32(derivation.paymentKey) + const vk = VKey.fromPrivateKey(paymentSk) + const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : payload + const _sig = PrivateKey.sign(paymentSk, bytes) + const sigHex = VKey.toHex(vk) // TODO: Convert signature properly + return { payload, signature: sigHex } + }) + } + + // Promise API runs the Effect implementations + return { + type: "signing", + address: () => runEffect(effectInterface.address()), + rewardAddress: () => runEffect(effectInterface.rewardAddress()), + signTx: (txOrHex, context) => runEffect(effectInterface.signTx(txOrHex, context)), + signMessage: (address, payload) => runEffect(effectInterface.signMessage(address, payload)), + Effect: effectInterface + } +} + /** * Construct an ApiWallet wrapping a CIP-30 browser wallet API. * @@ -399,11 +488,11 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): if (cachedAddress) return cachedAddress const used = yield* Effect.tryPromise({ try: () => api.getUsedAddresses(), - catch: (cause) => new WalletNew.WalletError({ message: "Failed to get used addresses", cause }) + catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error }) }) const unused = yield* Effect.tryPromise({ try: () => api.getUnusedAddresses(), - catch: (cause) => new WalletNew.WalletError({ message: "Failed to get unused addresses", cause }) + catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error }) }) const addr = used[0] ?? unused[0] if (!addr) { @@ -417,7 +506,7 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): if (cachedReward !== null) return cachedReward const rewards = yield* Effect.tryPromise({ try: () => api.getRewardAddresses(), - catch: (cause) => new WalletNew.WalletError({ message: "Failed to get reward addresses", cause }) + catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error }) }) cachedReward = rewards[0] ?? null return cachedReward @@ -425,8 +514,8 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): // Effect implementations are the source of truth const effectInterface: WalletNew.ApiWalletEffect = { - address: getPrimaryAddress, - rewardAddress: getPrimaryRewardAddress, + address: () => getPrimaryAddress, + rewardAddress: () => getPrimaryRewardAddress, signTx: (txOrHex: Transaction.Transaction | string, _context?: { utxos?: ReadonlyArray }) => Effect.gen(function* () { const cbor = typeof txOrHex === "string" ? txOrHex : Transaction.toCBORHex(txOrHex) @@ -435,7 +524,7 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): catch: (cause) => new WalletNew.WalletError({ message: "User rejected transaction signing", cause }) }) return yield* TransactionWitnessSet.Either.fromCBORHex(witnessHex).pipe( - Effect.mapError((cause) => new WalletNew.WalletError({ message: "Failed to decode witness set", cause })) + Effect.mapError((cause) => new WalletNew.WalletError({ message: cause.message, cause })) ) }), signMessage: (address: Address.Address | RewardAddress.RewardAddress, payload: WalletNew.Payload) => @@ -451,7 +540,7 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): const cbor = typeof txOrHex === "string" ? txOrHex : Transaction.toCBORHex(txOrHex) return yield* Effect.tryPromise({ try: () => api.submitTx(cbor), - catch: (cause) => new WalletNew.WalletError({ message: "Failed to submit transaction", cause }) + catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error }) }) }) } @@ -460,8 +549,8 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): return { type: "api" as const, api, - address: Effect.runPromise(effectInterface.address), - rewardAddress: Effect.runPromise(effectInterface.rewardAddress), + address: () => Effect.runPromise(effectInterface.address()), + rewardAddress: () => Effect.runPromise(effectInterface.rewardAddress()), signTx: (txOrHex, context) => Effect.runPromise(effectInterface.signTx(txOrHex, context)), signMessage: (address, payload) => Effect.runPromise(effectInterface.signMessage(address, payload)), submitTx: (txOrHex) => Effect.runPromise(effectInterface.submitTx(txOrHex)), @@ -477,9 +566,11 @@ const createApiWallet = (_network: WalletNew.Network, config: ApiWalletConfig): * @since 2.0.0 * @category constructors */ -const createSigningWalletClient = (network: NetworkId, config: SeedWalletConfig): SigningWalletClient => { +const createSigningWalletClient = (network: NetworkId, config: SeedWalletConfig | PrivateKeyWalletConfig): SigningWalletClient => { const walletNetwork = toWalletNetwork(network) - const wallet = createSigningWallet(walletNetwork, config) + const wallet = config.type === "seed" + ? createSigningWallet(walletNetwork, config) + : createPrivateKeyWallet(walletNetwork, config) const networkId = normalizeNetworkId(network) return { @@ -530,16 +621,18 @@ const createApiWalletClient = (network: NetworkId, config: ApiWalletConfig): Api const createSigningClient = ( network: NetworkId, providerConfig: ProviderConfig, - walletConfig: SeedWalletConfig | ApiWalletConfig + walletConfig: SeedWalletConfig | PrivateKeyWalletConfig | ApiWalletConfig ): SigningClient => { const provider = createProvider(providerConfig) const walletNetwork = toWalletNetwork(network) - // Create appropriate wallet based on type + // Create appropriate wallet based on type (both are now sync) const wallet = walletConfig.type === "seed" ? createSigningWallet(walletNetwork, walletConfig) - : createApiWallet(walletNetwork, walletConfig) + : walletConfig.type === "private-key" + ? createPrivateKeyWallet(walletNetwork, walletConfig) + : createApiWallet(walletNetwork, walletConfig) // Effect implementations are the source of truth const effectInterface = { @@ -547,25 +640,34 @@ const createSigningClient = ( ...provider.Effect, // Provider methods override wallet methods (e.g., submitTx uses ProviderError not WalletError) // Wallet-scoped convenience methods as Effects - expose union types (Effect-TS idiom) getWalletUtxos: () => - Effect.flatMap(wallet.Effect.address, (addr) => provider.Effect.getUtxos(addr)), + Effect.flatMap(wallet.Effect.address(), (addr) => provider.Effect.getUtxos(addr)), getWalletDelegation: () => - Effect.flatMap(wallet.Effect.rewardAddress, (rewardAddr) => { + Effect.flatMap(wallet.Effect.rewardAddress(), (rewardAddr) => { if (!rewardAddr) return Effect.fail(new Provider.ProviderError({ message: "No reward address configured", cause: null })) return provider.Effect.getDelegation(rewardAddr) - }), - newTx: (_utxos?: any) => { - return Effect.fail(new Provider.ProviderError({ message: "newTx not yet implemented", cause: null })) - } + }) } // Combine provider + signing wallet via spreading + // Define getWalletUtxos first so we can reference it in newTx + const getWalletUtxos = () => Effect.runPromise(effectInterface.getWalletUtxos()) + return { ...provider, ...wallet, // Promise methods call Effect implementations - getWalletUtxos: () => Effect.runPromise(effectInterface.getWalletUtxos()), + getWalletUtxos, getWalletDelegation: () => Effect.runPromise(effectInterface.getWalletDelegation()), - newTx: (_utxos?: any) => Effect.runPromise(effectInterface.newTx(_utxos)), + // Transaction builder - creates a new builder instance + newTx: (): TransactionBuilder => { + // Wallet provides change address and UTxO fetching via wallet.Effect.address() + // The wallet is passed to the builder config, which handles address and UTxO resolution automatically + // Protocol parameters are auto-fetched from provider during build() + return makeTxBuilder({ + provider, // Pass provider for submission + wallet // Pass wallet for signing + }) + }, // Effect namespace Effect: effectInterface } @@ -671,14 +773,14 @@ export function createClient(config: { wallet: ReadOnlyWalletConfig }): ReadOnlyClient -// Provider + Seed Wallet → SigningClient (TODO: implement) +// Provider + Seed Wallet → SigningClient export function createClient(config: { network?: NetworkId provider: ProviderConfig wallet: SeedWalletConfig }): SigningClient -// Provider + API Wallet → SigningClient (TODO: implement) +// Provider + API Wallet → SigningClient export function createClient(config: { network?: NetworkId provider: ProviderConfig @@ -691,26 +793,31 @@ export function createClient(config: { network?: NetworkId; provider: ProviderCo // ReadOnly Wallet only → ReadOnlyWalletClient export function createClient(config: { network?: NetworkId; wallet: ReadOnlyWalletConfig }): ReadOnlyWalletClient -// Seed Wallet only → SigningWalletClient (TODO: implement) +// Seed Wallet only → SigningWalletClient export function createClient(config: { network?: NetworkId; wallet: SeedWalletConfig }): SigningWalletClient -// API Wallet only → ApiWalletClient (TODO: implement) +// Private Key Wallet only → SigningWalletClient +export function createClient(config: { network?: NetworkId; wallet: PrivateKeyWalletConfig }): SigningWalletClient + +// API Wallet only → ApiWalletClient export function createClient(config: { network?: NetworkId; wallet: ApiWalletConfig }): ApiWalletClient // Network only or minimal → MinimalClient export function createClient(config?: { network?: NetworkId }): MinimalClient -// Implementation signature - handles all cases -export function createClient(config?: { - network?: NetworkId - provider?: ProviderConfig - wallet?: WalletConfig -}): +// Implementation signature - handles all cases (all synchronous now) +export function createClient( + config?: { + network?: NetworkId + provider?: ProviderConfig + wallet?: WalletConfig + }, +): | MinimalClient - | ProviderOnlyClient - | ReadOnlyWalletClient | ReadOnlyClient | SigningClient + | ProviderOnlyClient + | ReadOnlyWalletClient | SigningWalletClient | ApiWalletClient { const network = config?.network ?? "mainnet" @@ -722,6 +829,8 @@ export function createClient(config?: { return createReadOnlyClient(network, config.provider, config.wallet) case "seed": return createSigningClient(network, config.provider, config.wallet) + case "private-key": + return createSigningClient(network, config.provider, config.wallet) case "api": return createSigningClient(network, config.provider, config.wallet) } @@ -734,6 +843,8 @@ export function createClient(config?: { return createReadOnlyWalletClient(network, config.wallet) case "seed": return createSigningWalletClient(network, config.wallet) + case "private-key": + return createSigningWalletClient(network, config.wallet) case "api": return createApiWalletClient(network, config.wallet) } diff --git a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts index 094ab526..2b47e8d9 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts @@ -5,6 +5,7 @@ import { Effect, Schedule, Schema } from "effect" +import * as Bytes from "../../../core/Bytes.js" import type * as Address from "../../Address.js" import type * as Credential from "../../Credential.js" import type * as OutRef from "../../OutRef.js" @@ -244,17 +245,24 @@ export const awaitTx = (baseUrl: string, projectId?: string) => * Returns: (baseUrl, projectId?) => (cbor) => Effect */ export const submitTx = (baseUrl: string, projectId?: string) => - (cbor: string) => - withRateLimit( - HttpUtils.postJson( + (cbor: string) => { + // Convert CBOR hex string to Uint8Array for submission + const cborBytes = Bytes.fromHex(cbor) + + // Create headers without Content-Type (will be set by postUint8Array) + const headers = projectId ? { "project_id": projectId } : undefined + + return withRateLimit( + HttpUtils.postUint8Array( `${baseUrl}/tx/submit`, - { cbor }, + cborBytes, Blockfrost.BlockfrostSubmitResponse, - createHeaders(projectId) + headers ).pipe( Effect.mapError(wrapError("submitTx")) ) ) + } /** * Evaluate transaction diff --git a/packages/evolution/src/sdk/provider/internal/HttpUtils.ts b/packages/evolution/src/sdk/provider/internal/HttpUtils.ts index dbebcda2..f2a2a7c1 100644 --- a/packages/evolution/src/sdk/provider/internal/HttpUtils.ts +++ b/packages/evolution/src/sdk/provider/internal/HttpUtils.ts @@ -71,10 +71,12 @@ export const postUint8Array = ( ) => Effect.gen(function* () { let request = HttpClientRequest.post(url) + // Set body with content-type request = HttpClientRequest.bodyUint8Array(request, body, "application/cbor") - request = HttpClientRequest.setHeaders(request, { - ...(headers || {}) - }) + // Set additional headers AFTER body (so they don't get overridden) + if (headers) { + request = HttpClientRequest.setHeaders(request, headers) + } const response = yield* HttpClient.execute(request) const filteredResponse = yield* filterStatusOk(response) diff --git a/packages/evolution/src/sdk/wallet/Derivation.ts b/packages/evolution/src/sdk/wallet/Derivation.ts index cf4ffb47..0d41feeb 100644 --- a/packages/evolution/src/sdk/wallet/Derivation.ts +++ b/packages/evolution/src/sdk/wallet/Derivation.ts @@ -1,7 +1,8 @@ import { mnemonicToEntropy } from "@scure/bip39" import { wordlist as English } from "@scure/bip39/wordlists/english" import * as Data from "effect/Data" -import * as Either from "effect/Either" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" import * as AddressEras from "../../core/AddressEras.js" import * as BaseAddress from "../../core/BaseAddress.js" @@ -23,12 +24,17 @@ export class DerivationError extends Data.TaggedError("DerivationError")<{ * - address: bech32 payment address (addr... / addr_test...) * - rewardAddress: bech32 reward address (stake... / stake_test...) * - paymentKey / stakeKey: ed25519e_sk bech32 private keys + * - keyStore: Map of KeyHash hex -> PrivateKey for signing operations + * - paymentKhHex / stakeKhHex: KeyHash hex strings for quick lookup */ export type SeedDerivationResult = { address: SdkAddress.Address rewardAddress: SdkRewardAddress.RewardAddress | undefined paymentKey: string stakeKey: string | undefined + keyStore: Map + paymentKhHex: string + stakeKhHex: string | undefined } export const walletFromSeed = ( @@ -39,10 +45,10 @@ export const walletFromSeed = ( accountIndex?: number network?: "Mainnet" | "Testnet" | "Custom" } = {} -) => - Either.gen(function* () { +): Effect.Effect => { + return Effect.gen(function* () { const { accountIndex = 0, addressType = "Base", network = "Mainnet" } = options - const entropy = yield* Either.try({ + const entropy = yield* Effect.try({ try: () => mnemonicToEntropy(seed, English), catch: (cause) => new DerivationError({ message: "Invalid seed phrase", cause }) }) @@ -64,37 +70,73 @@ export const walletFromSeed = ( const address = addressType === "Base" - ? yield* AddressEras.Either.toBech32( - new BaseAddress.BaseAddress({ - networkId, - paymentCredential: paymentKeyHash, - stakeCredential: stakeKeyHash - }) - ) - : yield* AddressEras.Either.toBech32( - new EnterpriseAddress.EnterpriseAddress({ - networkId, - paymentCredential: paymentKeyHash - }) - ) + ? yield* Effect.try({ + try: () => { + const result = AddressEras.Either.toBech32( + new BaseAddress.BaseAddress({ + networkId, + paymentCredential: paymentKeyHash, + stakeCredential: stakeKeyHash + }) + ) + if (result._tag === "Left") throw result.left + return result.right + }, + catch: (cause) => new DerivationError({ message: (cause as Error).message, cause: cause as Error }) + }) + : yield* Effect.try({ + try: () => { + const result = AddressEras.Either.toBech32( + new EnterpriseAddress.EnterpriseAddress({ + networkId, + paymentCredential: paymentKeyHash + }) + ) + if (result._tag === "Left") throw result.left + return result.right + }, + catch: (cause) => new DerivationError({ message: (cause as Error).message, cause: cause as Error }) + }) const rewardAddress = addressType === "Base" - ? yield* AddressEras.Either.toBech32( - new RewardAccount.RewardAccount({ - networkId, - stakeCredential: stakeKeyHash - }) - ) + ? yield* Effect.try({ + try: () => { + const result = AddressEras.Either.toBech32( + new RewardAccount.RewardAccount({ + networkId, + stakeCredential: stakeKeyHash + }) + ) + if (result._tag === "Left") throw result.left + return result.right + }, + catch: (cause) => new DerivationError({ message: (cause as Error).message, cause: cause as Error }) + }) : undefined + // Build keyStore: map KeyHash hex -> PrivateKey for signing + const keyStore = new Map() + const paymentKhHex = KeyHash.toHex(paymentKeyHash) + keyStore.set(paymentKhHex, paymentKey) + + let stakeKhHex: string | undefined + if (addressType === "Base") { + stakeKhHex = KeyHash.toHex(stakeKeyHash) + keyStore.set(stakeKhHex, stakeKey) + } + return { address, rewardAddress, paymentKey: PrivateKey.toBech32(paymentKey), - stakeKey: addressType === "Base" ? PrivateKey.toBech32(stakeKey) : undefined + stakeKey: addressType === "Base" ? PrivateKey.toBech32(stakeKey) : undefined, + keyStore, + paymentKhHex, + stakeKhHex } }) +} /** * Derive only the bech32 private keys (ed25519e_sk...) from a seed. */ @@ -215,11 +257,25 @@ export function walletFromBip32( ) : undefined + // Build keyStore + const keyStore = new Map() + const paymentKhHex = KeyHash.toHex(paymentKeyHash) + keyStore.set(paymentKhHex, paymentKey) + + let stakeKhHex: string | undefined + if (addressType === "Base") { + stakeKhHex = KeyHash.toHex(stakeKeyHash) + keyStore.set(stakeKhHex, stakeKey) + } + return { address, rewardAddress, paymentKey: PrivateKey.toBech32(paymentKey), - stakeKey: addressType === "Base" ? PrivateKey.toBech32(stakeKey) : undefined + stakeKey: addressType === "Base" ? PrivateKey.toBech32(stakeKey) : undefined, + keyStore, + paymentKhHex, + stakeKhHex } } @@ -234,37 +290,66 @@ export function walletFromPrivateKey( addressType?: "Base" | "Enterprise" network?: "Mainnet" | "Testnet" | "Custom" } = {} -): SeedDerivationResult { - const { stakeKeyBech32, addressType = stakeKeyBech32 ? "Base" : "Enterprise", network = "Mainnet" } = options - const paymentKey = PrivateKey.fromBech32(paymentKeyBech32) - const paymentKeyHash = KeyHash.fromPrivateKey(paymentKey) +): Effect.Effect { + return Effect.gen(function* () { + const { stakeKeyBech32, addressType = stakeKeyBech32 ? "Base" : "Enterprise", network = "Mainnet" } = options + + // Use the Effect-based Either API from PrivateKey module - can yield directly on Either + const paymentKey = yield* Effect.mapError( + // PrivateKey.Either.fromBech32(paymentKeyBech32), + Schema.decode(PrivateKey.FromBech32)(paymentKeyBech32), + (cause) => new DerivationError({ message: cause.message, cause }) + ) + const paymentKeyHash = KeyHash.fromPrivateKey(paymentKey) - const networkId = network === "Mainnet" ? 1 : 0 - const address = - addressType === "Base" - ? (() => { - if (!stakeKeyBech32) throw new Error("stakeKeyBech32 required for Base address") - const stakeKey = PrivateKey.fromBech32(stakeKeyBech32) - const stakeKeyHash = KeyHash.fromPrivateKey(stakeKey) - return AddressEras.toBech32( - new BaseAddress.BaseAddress({ networkId, paymentCredential: paymentKeyHash, stakeCredential: stakeKeyHash }) - ) - })() - : AddressEras.toBech32(new EnterpriseAddress.EnterpriseAddress({ networkId, paymentCredential: paymentKeyHash })) + const networkId = network === "Mainnet" ? 1 : 0 + + let address: string + let stakeKey: PrivateKey.PrivateKey | undefined + let stakeKeyHash: KeyHash.KeyHash | undefined + + if (addressType === "Base") { + if (!stakeKeyBech32) { + return yield* Effect.fail(new DerivationError({ message: "stakeKeyBech32 required for Base address" })) + } + stakeKey = yield* Effect.mapError( + PrivateKey.Either.fromBech32(stakeKeyBech32), + (cause) => new DerivationError({ message: cause.message, cause }) + ) + stakeKeyHash = KeyHash.fromPrivateKey(stakeKey) + address = AddressEras.toBech32( + new BaseAddress.BaseAddress({ networkId, paymentCredential: paymentKeyHash, stakeCredential: stakeKeyHash }) + ) + } else { + address = AddressEras.toBech32( + new EnterpriseAddress.EnterpriseAddress({ networkId, paymentCredential: paymentKeyHash }) + ) + } - const rewardAddress = - addressType === "Base" && stakeKeyBech32 - ? (() => { - const stakeKey = PrivateKey.fromBech32(stakeKeyBech32) - const stakeKeyHash = KeyHash.fromPrivateKey(stakeKey) - return AddressEras.toBech32(new RewardAccount.RewardAccount({ networkId, stakeCredential: stakeKeyHash })) - })() - : undefined + const rewardAddress = + addressType === "Base" && stakeKeyHash + ? AddressEras.toBech32(new RewardAccount.RewardAccount({ networkId, stakeCredential: stakeKeyHash })) + : undefined - return { - address, - rewardAddress, - paymentKey: paymentKeyBech32, - stakeKey: stakeKeyBech32 - } + // Build keyStore + const keyStore = new Map() + const paymentKhHex = KeyHash.toHex(paymentKeyHash) + keyStore.set(paymentKhHex, paymentKey) + + let stakeKhHex: string | undefined + if (addressType === "Base" && stakeKey && stakeKeyHash) { + stakeKhHex = KeyHash.toHex(stakeKeyHash) + keyStore.set(stakeKhHex, stakeKey) + } + + return { + address, + rewardAddress, + paymentKey: paymentKeyBech32, + stakeKey: stakeKeyBech32, + keyStore, + paymentKhHex, + stakeKhHex + } + }) } diff --git a/packages/evolution/src/sdk/wallet/Wallet.ts b/packages/evolution/src/sdk/wallet/Wallet.ts index a7fe3911..5ee551cf 100644 --- a/packages/evolution/src/sdk/wallet/Wallet.ts +++ b/packages/evolution/src/sdk/wallet/Wallet.ts @@ -1,5 +1,5 @@ // Parent imports (../../) -import * as Either from "effect/Either" +import * as Effect from "effect/Effect" import * as CoreAddressStructure from "../../core/AddressStructure.js" import * as Ed25519Signature from "../../core/Ed25519Signature.js" @@ -139,12 +139,14 @@ export function makeWalletFromSeed( ): Wallet { const config = { overriddenUTxOs: [] as Array } - const { address, paymentKey, rewardAddress, stakeKey } = walletFromSeed(seed, { - addressType: options?.addressType ?? "Base", - accountIndex: options?.accountIndex ?? 0, - password: options?.password, - network - }).pipe(Either.getOrThrow) + const { address, paymentKey, rewardAddress, stakeKey } = Effect.runSync( + walletFromSeed(seed, { + addressType: options?.addressType ?? "Base", + accountIndex: options?.accountIndex ?? 0, + password: options?.password, + network + }) + ) // Minimal keystore: map KeyHash hex -> PrivateKey type KeyStore = Map diff --git a/packages/evolution/src/sdk/wallet/WalletNew.ts b/packages/evolution/src/sdk/wallet/WalletNew.ts index 112273ff..2bd1acb1 100644 --- a/packages/evolution/src/sdk/wallet/WalletNew.ts +++ b/packages/evolution/src/sdk/wallet/WalletNew.ts @@ -50,8 +50,8 @@ export interface SignedMessage { * @category interfaces */ export interface ReadOnlyWalletEffect { - readonly address: Effect.Effect - readonly rewardAddress: Effect.Effect + readonly address: () => Effect.Effect + readonly rewardAddress: () => Effect.Effect } export interface ReadOnlyWallet extends EffectToPromiseAPI { diff --git a/packages/evolution/src/utils/effect-runtime.ts b/packages/evolution/src/utils/effect-runtime.ts new file mode 100644 index 00000000..2bdae464 --- /dev/null +++ b/packages/evolution/src/utils/effect-runtime.ts @@ -0,0 +1,117 @@ +import { Cause, Effect, Exit } from "effect" + +/** + * Patterns to filter from stack traces - Effect.ts internal implementation details + */ +const EFFECT_INTERNAL_PATTERNS = [ + /node_modules\/.pnpm\/effect@.*\/node_modules\/effect\//, + /at FiberRuntime\./, + /at EffectPrimitive\./, + /at Object\.Iterator/, + /at runLoop/, + /at evaluateEffect/, + /at body \(/, + /effect_instruction_i\d+/, + /at pipeArguments/, + /at pipe \(/, + /at Arguments\./, + /at Module\./, + /at issue \(/, + /at \.\.\.$/ // Lines like "... 7 lines matching cause stack trace ..." +] + +/** + * Clean a single error's stack trace by removing Effect.ts internals + */ +function cleanStackTrace(stack: string | undefined): string { + if (!stack) return "" + + const lines = stack.split("\n") + const cleaned = lines.filter(line => { + // Keep the error message line (first line) + if (!line.trim().startsWith("at ")) return true + + // Filter out Effect.ts internal lines + return !EFFECT_INTERNAL_PATTERNS.some(pattern => pattern.test(line)) + }) + + return cleaned.join("\n") +} + +/** + * Recursively clean error chain (error and all causes) + */ +function cleanErrorChain(error: any): any { + if (!error) return error + + // Clean current error's stack + if (error.stack) { + error.stack = cleanStackTrace(error.stack) + } + + // Recursively clean cause chain + if (error.cause) { + error.cause = cleanErrorChain(error.cause) + } + + // Handle Effect.ts internal cause field + if (error[Symbol.for("effect/Runtime/FiberFailure/Cause")]) { + const cause = error[Symbol.for("effect/Runtime/FiberFailure/Cause")] + if (cause && typeof cause === "object") { + if (cause.error) { + cause.error = cleanErrorChain(cause.error) + } + } + } + + return error +} + +/** + * Run an Effect and convert it to a Promise with clean error handling. + * + * - Executes the Effect using Effect.runPromiseExit + * - On failure, extracts the error from the Exit and cleans stack traces + * - Removes Effect.ts internal stack frames for cleaner error messages + * - Throws the cleaned error for standard Promise error handling + * + * @example + * ```typescript + * import { Effect } from "effect" + * import { runEffect } from "@evolution-sdk/evolution/utils/effect-runtime" + * + * const myEffect = Effect.succeed(42) + * + * async function example() { + * try { + * const result = await runEffect(myEffect) + * console.log(result) + * } catch (error) { + * // Error with clean stack trace, no Effect.ts internals + * console.error(error) + * } + * } + * ``` + * + * @param effect - The Effect to execute + * @returns Promise that resolves to the Effect's success value + * @throws The Effect's error with cleaned stack trace + * + * @since 2.0.0 + * @category utilities + */ +export async function runEffect(effect: Effect.Effect): Promise { + const exit = await Effect.runPromiseExit(effect) + + if (Exit.isFailure(exit)) { + // Extract the error from the failure + const error = Cause.squash(exit.cause) + + // Clean the error's stack trace + const cleanedError = cleanErrorChain(error) + + throw cleanedError + } + + return exit.value +} diff --git a/packages/evolution/test/TxBuilder.CoinSelectionFailures.test.ts b/packages/evolution/test/TxBuilder.CoinSelectionFailures.test.ts index 4470b733..652cb40b 100644 --- a/packages/evolution/test/TxBuilder.CoinSelectionFailures.test.ts +++ b/packages/evolution/test/TxBuilder.CoinSelectionFailures.test.ts @@ -19,9 +19,6 @@ const RECEIVER_ADDRESS = "addr_test1qpw0djgj0x59ngrjvqthn7enhvruxnsavsw5th63la3mjel3tkc974sr23jmlzgq5zda4gtv8k9cy38756r9y3qgmkqqjz6aa7" const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] } describe("Insufficient Lovelace", () => { @@ -31,12 +28,12 @@ describe("Insufficient Lovelace", () => { createTestUtxo({ txHash: "tx1", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 1_000_000n }) ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(5_000_000n) }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) it("should fail when lovelace covers payment but not payment + fees", async () => { @@ -45,12 +42,12 @@ describe("Insufficient Lovelace", () => { createTestUtxo({ txHash: "tx1", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 2_000_000n }) ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(1_950_000n) }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Cannot create valid change/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Cannot create valid change/) }) it("should fail with multiple small UTxOs that sum to insufficient amount", async () => { @@ -63,12 +60,12 @@ describe("Insufficient Lovelace", () => { createTestUtxo({ txHash: "tx5", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 100_000n }) ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(1_000_000n) }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) }) @@ -96,12 +93,12 @@ describe("Missing Native Assets", () => { [tokenB]: 100n // Requesting token that doesn't exist } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) it("should fail when multiple assets requested but one is missing", async () => { @@ -135,12 +132,12 @@ describe("Missing Native Assets", () => { [tokenC]: 10n // Missing token } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) }) @@ -165,12 +162,12 @@ describe("Insufficient Native Asset Quantity", () => { [tokenA]: 100n // Need 100, only have 50 } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) it("should fail when tokens are fragmented across UTxOs but total is insufficient", async () => { @@ -207,12 +204,12 @@ describe("Insufficient Native Asset Quantity", () => { [tokenA]: 100n // Need 100, only have 90 total } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) it("should fail when one of multiple required assets is insufficient", async () => { @@ -242,12 +239,12 @@ describe("Insufficient Native Asset Quantity", () => { [tokenB]: 100n // Insufficient } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) }) @@ -255,13 +252,13 @@ describe("Complex Mixed Failures", () => { it("should fail with empty wallet (no UTxOs)", async () => { const utxos: Array = [] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(1_000_000n) }) // Empty wallet fails coin selection - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed/) }) it("should fail when UTxOs exist but all are too small for min UTxO + fees", async () => { @@ -271,12 +268,12 @@ describe("Complex Mixed Failures", () => { (_, i) => createTestUtxo({ txHash: `tx${i}`, outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 1000n }) // 0.001 ADA each ) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(50_000n) }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) it("should fail with sufficient lovelace but missing native asset", async () => { @@ -293,12 +290,12 @@ describe("Complex Mixed Failures", () => { [tokenA]: 1n // Even 1 token will fail } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) it("should fail when combined shortfalls across lovelace and multiple assets", async () => { @@ -328,12 +325,12 @@ describe("Complex Mixed Failures", () => { [tokenB]: 50n // Need 50, have 5 } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) }) @@ -346,13 +343,13 @@ describe("Edge Case: drainTo Cannot Save Insufficient Funds", () => { createTestUtxo({ txHash: "tx1", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 1_000_000n }) ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(5_000_000n) // Way more than available }) await expect( - builder.build({ drainTo: 0, useStateMachine: true, useV3: true }) // drainTo cannot save this + builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, drainTo: 0, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS }) // drainTo cannot save this ).rejects.toThrow(/Coin selection failed for/) }) @@ -363,11 +360,11 @@ describe("Edge Case: drainTo Cannot Save Insufficient Funds", () => { createTestUtxo({ txHash: "tx1", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 800_000n }) ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ + const builder = makeTxBuilder(baseConfig).payToAddress({ address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(2_000_000n) }) - await expect(builder.build({ useStateMachine: true, useV3: true })).rejects.toThrow(/Coin selection failed for/) + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useStateMachine: true, useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow(/Coin selection failed for/) }) }) diff --git a/packages/evolution/test/TxBuilder.EdgeCases.test.ts b/packages/evolution/test/TxBuilder.EdgeCases.test.ts index 52adf9b3..d214d0a0 100644 --- a/packages/evolution/test/TxBuilder.EdgeCases.test.ts +++ b/packages/evolution/test/TxBuilder.EdgeCases.test.ts @@ -23,9 +23,6 @@ const CHANGE_ADDRESS = TESTNET_ADDRESSES[0] const RECEIVER_ADDRESS = TESTNET_ADDRESSES[1] const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] } describe("TxBuilder P0 Edge Cases - Reselection Loop Boundaries", () => { @@ -37,7 +34,7 @@ describe("TxBuilder P0 Edge Cases - Reselection Loop Boundaries", () => { createTestUtxo({ txHash: "tx4", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 100_000n }) ] - const txBuilder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const txBuilder = makeTxBuilder(baseConfig) // Try to build transaction requiring 5M lovelace (impossible with 400k total) await expect( @@ -46,7 +43,7 @@ describe("TxBuilder P0 Edge Cases - Reselection Loop Boundaries", () => { address: RECEIVER_ADDRESS, assets: Assets.fromLovelace(5_000_000n) }) - .build({ useV3: true }) + .build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) ).rejects.toThrow() }) @@ -140,14 +137,14 @@ describe("TxBuilder P0 Edge Cases - Reselection Loop Boundaries", () => { [tokenC]: 100n } - const txBuilder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const txBuilder = makeTxBuilder(baseConfig) const signBuilder = await txBuilder .payToAddress({ address: RECEIVER_ADDRESS, assets: paymentAssets }) - .build({ useV3: true }) + .build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() @@ -197,7 +194,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => { createTestUtxo({ txHash: "tx2", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 500_000n }) ] - const txBuilder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const txBuilder = makeTxBuilder(baseConfig) // Payment that will leave insufficient change with the first UTxO const signBuilder = await txBuilder @@ -205,7 +202,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => { address: RECEIVER_ADDRESS, assets: { lovelace: 1_000_000n } }) - .build({ useV3: true }) + .build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() @@ -274,7 +271,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => { }) ] - const txBuilder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const txBuilder = makeTxBuilder(baseConfig) // Small payment to leave change with max-length asset names const signBuilder = await txBuilder @@ -282,7 +279,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => { address: RECEIVER_ADDRESS, assets: { lovelace: 2_000_000n } }) - .build({ useV3: true }) + .build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() @@ -356,7 +353,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => { createTestUtxo({ txHash: "tx6", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 200_000n }) ] - const txBuilder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const txBuilder = makeTxBuilder(baseConfig) // Payment sized to trigger cascading reselection // Initial 2 UTxOs: 4.4M total @@ -369,7 +366,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => { address: RECEIVER_ADDRESS, assets: { lovelace: 4_000_000n } // 4.0 ADA (no native assets in payment) }) - .build({ useV3: true }) + .build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() diff --git a/packages/evolution/test/TxBuilder.InsufficientChange.test.ts b/packages/evolution/test/TxBuilder.InsufficientChange.test.ts index ba64e051..07381d46 100644 --- a/packages/evolution/test/TxBuilder.InsufficientChange.test.ts +++ b/packages/evolution/test/TxBuilder.InsufficientChange.test.ts @@ -77,35 +77,34 @@ const createSufficientUtxo = (lovelace: bigint = 100_000_000n): UTxO.UTxO => createTestUtxo({ txHash: "a".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace }) const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] } describe("Fallback Tier 3: onInsufficientChange Strategy", () => { it("should throw error by default when change is insufficient (safe default)", async () => { // Arrange: UTxO with insufficient leftover for change output const utxo = createMinimalUtxo() - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) // Act & Assert: Should fail with default 'error' strategy // This is the SAFE default - prevents accidental fund loss - await expect(builder.build({ useV3: true })).rejects.toThrow() + await expect(builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: [utxo], useV3: true, protocolParameters: PROTOCOL_PARAMS })).rejects.toThrow() }) it("should burn leftover as extra fee when onInsufficientChange='burn'", async () => { // Arrange: Same insufficient leftover scenario const utxo = createMinimalUtxo() - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) // Act: Explicitly consent to burning leftover - const signBuilder = await builder.build({ onInsufficientChange: "burn", useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: [utxo], onInsufficientChange: "burn", useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -133,17 +132,21 @@ describe("Fallback Precedence: drainTo before onInsufficientChange", () => { it("should use drainTo (Fallback #1) before checking onInsufficientChange (Fallback #2)", async () => { // Arrange: Insufficient change + both fallbacks configured const utxo = createMinimalUtxo() - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) // Act: Configure both drainTo and onInsufficientChange='error' // drainTo should take precedence (Fallback #1 before #2) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], drainTo: 0, // Fallback #1: Drain into first output onInsufficientChange: "error", // Fallback #2: Would error, but shouldn't reach here - useV3: true + useV3: true, + protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() @@ -168,15 +171,19 @@ describe("Normal Path: Sufficient Change (No Fallbacks)", () => { it("should create change output when sufficient funds available", async () => { // Arrange: UTxO with plenty of ADA const utxo = createSufficientUtxo(100_000_000n) // 100 ADA - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(10_000_000n) // 10 ADA payment - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(10_000_000n) // 10 ADA payment + }) // Act: Build with fallback configured (shouldn't be needed) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], onInsufficientChange: "error", // Configured but not reached - useV3: true + useV3: true, + protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() @@ -203,13 +210,14 @@ describe("Normal Path: Sufficient Change (No Fallbacks)", () => { it("should handle exact amount with drainTo without triggering fallbacks", async () => { // Arrange: UTxO with exact amount needed const utxo = createMinimalUtxo() - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) // Act: Use drainTo for exact amount scenarios - const signBuilder = await builder.build({ drainTo: 0, useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: [utxo], drainTo: 0, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -246,14 +254,15 @@ describe("Edge Cases", () => { } ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) // Act: Build with drainTo to merge leftover into payment // Total: 2.2 ADA - 2.0 payment - 0.17 fee = 0.03 ADA leftover (insufficient for change) - const signBuilder = await builder.build({ drainTo: 0, useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, drainTo: 0, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -274,13 +283,14 @@ describe("Edge Cases", () => { // Arrange: Use the standard minimal UTxO (sufficient for tests) const utxo = createMinimalUtxo() - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) // Act: Burn small leftover - const signBuilder = await builder.build({ onInsufficientChange: "burn", useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: [utxo], onInsufficientChange: "burn", useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -329,13 +339,14 @@ describe("Multi-Asset minUTxO Calculation", () => { // Send most lovelace but keep all native assets // This creates leftover with: small lovelace + 10 assets - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [multiAssetUtxo] }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(2_500_000n) // Send 2.5 ADA only - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(2_500_000n) // Send 2.5 ADA only + }) // Act: Build transaction - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: [multiAssetUtxo], useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -401,13 +412,14 @@ describe("Fee Validation: Multiple Witnesses Edge Case", () => { })) // Build transaction that will select all 10 inputs - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(45_000_000n) // 45 ADA - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(45_000_000n) // 45 ADA + }) // Act: Build transaction - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -452,13 +464,14 @@ describe("Fee Validation: Multiple Witnesses Edge Case", () => { }) } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }).payToAddress({ - address: RECIPIENT_ADDRESS, - assets: Assets.fromLovelace(45_000_000n) - }) + const builder = makeTxBuilder(baseConfig) + .payToAddress({ + address: RECIPIENT_ADDRESS, + assets: Assets.fromLovelace(45_000_000n) + }) // Act - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ changeAddress: CHANGE_ADDRESS, availableUtxos: utxos, useV3: true, protocolParameters: PROTOCOL_PARAMS }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() diff --git a/packages/evolution/test/TxBuilder.Reselection.test.ts b/packages/evolution/test/TxBuilder.Reselection.test.ts index 882a6d18..23bef814 100644 --- a/packages/evolution/test/TxBuilder.Reselection.test.ts +++ b/packages/evolution/test/TxBuilder.Reselection.test.ts @@ -12,11 +12,10 @@ import * as FeeValidation from "../src/utils/FeeValidation.js" import { createTestUtxo } from "./utils/utxo-helpers.js" describe("TxBuilder Re-selection Loop", () => { - // ============================================================================ // Test Configuration // ============================================================================ - + const PROTOCOL_PARAMS = { minFeeCoefficient: 44n, minFeeConstant: 155_381n, @@ -37,9 +36,8 @@ describe("TxBuilder Re-selection Loop", () => { const RECEIVER_ADDRESS = TESTNET_ADDRESSES[1] const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] + // No wallet/provider - using manual mode + // changeAddress and availableUtxos provided via build options } // ============================================================================ @@ -59,14 +57,11 @@ describe("TxBuilder Re-selection Loop", () => { expect(validation.difference).toBe(0n) return validation - } - - // ============================================================================ + } // ============================================================================ // Basic Re-selection Tests // ============================================================================ describe("Basic Re-selection Scenarios", () => { - it("should build transaction with single UTxO - sufficient funds", async () => { const utxo: UTxO.UTxO = createTestUtxo({ txHash: "a".repeat(64), @@ -75,28 +70,32 @@ describe("TxBuilder Re-selection Loop", () => { lovelace: 10_000_000n }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) + }) - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() // Strict expectations - everything is deterministic expect(tx.body.inputs.length).toBe(1) expect(tx.body.outputs.length).toBe(2) // Payment + change - + const validation = await assertFeeValid(txWithFakeWitnesses, PROTOCOL_PARAMS) const size = await Effect.runPromise(calculateTransactionSize(txWithFakeWitnesses)) expect(size).toBeLessThanOrEqual(PROTOCOL_PARAMS.maxTxSize) - + // Strict expectations with deterministic values expect(size).toBe(294) // Exact transaction size with 1 witness expect(validation.actualFee).toBe(168_317n) // Exact deterministic fee - + // Verify exact output amounts expect(tx.body.outputs[0].amount.coin).toBe(2_000_000n) // Payment output // Change: 10M - 2M payment - 168,317 fee = 7,831,683 @@ -126,13 +125,18 @@ describe("TxBuilder Re-selection Loop", () => { lovelace: 1_000_000n }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo1, utxo2, utxo3] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) // 2 ADA payment - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) // 2 ADA payment + }) - const signBuilder = await builder.build({ drainTo: 0, useV3: true }) + const signBuilder = await builder.build({ + drainTo: 0, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2, utxo3], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -141,14 +145,14 @@ describe("TxBuilder Re-selection Loop", () => { expect(tx.body.inputs.length).toBe(2) // utxo1 (2.2M) + utxo2 (1M) after reselection // Should have 2 outputs: payment + change expect(tx.body.outputs.length).toBe(2) - + const validation = await assertFeeValid(txWithFakeWitnesses, PROTOCOL_PARAMS) const size = await Effect.runPromise(calculateTransactionSize(txWithFakeWitnesses)) expect(size).toBeLessThanOrEqual(PROTOCOL_PARAMS.maxTxSize) - + expect(size).toBe(330) // 2 inputs, 1 witness, 2 outputs expect(validation.actualFee).toBe(169_901n) // Fee for 2-input TX - + // Verify exact output amounts - reselection creates proper change output expect(tx.body.outputs[0].amount.coin).toBe(2_000_000n) // Payment output expect(tx.body.outputs[1].amount.coin).toBe(1_030_099n) // Change output (3.2M - 2M - fee) @@ -162,13 +166,19 @@ describe("TxBuilder Re-selection Loop", () => { lovelace: 1_000_000n }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(2_000_000n) // Requesting 2 ADA - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(2_000_000n) // Requesting 2 ADA + }) - await expect(builder.build({ useV3: true })).rejects.toThrow() + await expect( + builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], + protocolParameters: PROTOCOL_PARAMS + }) + ).rejects.toThrow() }) it("should handle exact amount with drainTo", async () => { @@ -184,28 +194,33 @@ describe("TxBuilder Re-selection Loop", () => { lovelace: exactAmount }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(paymentAmount) - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(paymentAmount) + }) - const signBuilder = await builder.build({ drainTo: 0, useV3: true }) + const signBuilder = await builder.build({ + drainTo: 0, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() expect(tx.body.inputs.length).toBe(1) // Should have 1 output (payment with drained amount) expect(tx.body.outputs.length).toBe(1) - + const validation = await assertFeeValid(txWithFakeWitnesses, PROTOCOL_PARAMS) const size = await Effect.runPromise(calculateTransactionSize(txWithFakeWitnesses)) expect(size).toBeLessThanOrEqual(PROTOCOL_PARAMS.maxTxSize) - - // Strict expectations with deterministic values + + // Strict expectations with deterministic values expect(size).toBe(227) // Exact transaction size with 1 witness, drainTo expect(validation.actualFee).toBe(165_369n) // Exact fee: 227*44 + 155_381 - + // Verify exact output amount (payment + drained leftover) expect(tx.body.outputs[0].amount.coin).toBe(2_004_631n) // 2_170_000 - 165_369 fee }) @@ -213,7 +228,7 @@ describe("TxBuilder Re-selection Loop", () => { it("should create change output instead of using drainTo when leftover contains native assets", async () => { // Scenario: drainTo is requested, but leftover has native assets // Expected: Transaction succeeds by creating proper change output (drainTo fallback skipped for native assets) - + const TOKEN_POLICY = "c".repeat(56) const TOKEN_NAME_1 = "544f4b454e31" // "TOKEN1" in hex const TOKEN_UNIT_1 = `${TOKEN_POLICY}${TOKEN_NAME_1}` @@ -227,24 +242,29 @@ describe("TxBuilder Re-selection Loop", () => { nativeAssets: { [TOKEN_UNIT_1]: 100n } }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - // Payment leaves leftover + token - // 3_000_000 - 2_000_000 - fee(~170k) = ~830k leftover + token - assets: Assets.fromLovelace(2_000_000n) - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + // Payment leaves leftover + token + // 3_000_000 - 2_000_000 - fee(~170k) = ~830k leftover + token + assets: Assets.fromLovelace(2_000_000n) + }) // DrainTo requested, but should create change output instead (native assets present) // Expected: Transaction succeeds with change output preserving native asset - const signBuilder = await builder.build({ drainTo: 0, useV3: true }) + const signBuilder = await builder.build({ + drainTo: 0, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], + protocolParameters: PROTOCOL_PARAMS + }) expect(signBuilder).toBeDefined() - + const tx = await signBuilder.toTransaction() - + // Should have payment + change output (native assets require change, drainTo skipped) expect(tx.body.outputs.length).toBe(2) - + // Verify we have 1 input expect(tx.body.inputs.length).toBe(1) }) @@ -255,7 +275,6 @@ describe("TxBuilder Re-selection Loop", () => { // ============================================================================ describe("Multi-Asset Re-selection", () => { - const TOKEN_POLICY = "c".repeat(56) const TOKEN_NAME = "544f4b454e" // "TOKEN" in hex const TOKEN_UNIT = `${TOKEN_POLICY}${TOKEN_NAME}` @@ -279,37 +298,41 @@ describe("TxBuilder Re-selection Loop", () => { }) // Pay 2 ADA + 50 tokens - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo1, utxo2] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: { - lovelace: 2_000_000n, - [TOKEN_UNIT]: 50n - } - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: { + lovelace: 2_000_000n, + [TOKEN_UNIT]: 50n + } + }) - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() // Should select both inputs expect(tx.body.inputs.length).toBe(2) - + // Should have payment output + change output with remaining 50 tokens expect(tx.body.outputs.length).toBe(2) - + // Verify both outputs exist expect(tx.body.outputs[0]).toBeDefined() expect(tx.body.outputs[1]).toBeDefined() - + const validation = await assertFeeValid(txWithFakeWitnesses, PROTOCOL_PARAMS) const size = await Effect.runPromise(calculateTransactionSize(txWithFakeWitnesses)) expect(size).toBeLessThanOrEqual(PROTOCOL_PARAMS.maxTxSize) - + // Strict expectations with deterministic values expect(size).toBe(513) // Exact transaction size with 2 witnesses, multi-asset expect(validation.actualFee).toBe(177_953n) // Exact fee: 513*44 + 155_381 - + // Verify exact output amounts (payment output) expect(tx.body.outputs[0].amount.coin).toBe(2_000_000n) // Change output: initially 2,826,799, adjusted by fee increase of 4,752 = 2,822,047 @@ -320,7 +343,7 @@ describe("TxBuilder Re-selection Loop", () => { const TOKEN_POLICY = "c".repeat(56) const TOKEN_NAME = "544f4b454e" // "TOKEN" in hex const TOKEN_UNIT = `${TOKEN_POLICY}${TOKEN_NAME}` - + // UTxO with ONLY ADA (no tokens) const utxo1: UTxO.UTxO = createTestUtxo({ txHash: "a".repeat(64), @@ -328,7 +351,7 @@ describe("TxBuilder Re-selection Loop", () => { address: TESTNET_ADDRESSES[0], lovelace: 10_000_000n }) - + // UTxO with tokens (available for coin selection) const utxo2: UTxO.UTxO = createTestUtxo({ txHash: "b".repeat(64), @@ -337,36 +360,39 @@ describe("TxBuilder Re-selection Loop", () => { lovelace: 3_000_000n, nativeAssets: { [TOKEN_UNIT]: 200n } }) - + // Config with both utxos available for automatic selection const builderConfig: TxBuilderConfig = { - ...baseConfig, - availableUtxos: [utxo1, utxo2] + ...baseConfig } - + // Payment requires tokens that utxo1 doesn't have - const builder = makeTxBuilder(builderConfig) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: { - lovelace: 2_000_000n, - [TOKEN_UNIT]: 100n // Requires tokens! - } - }) - - const signBuilder = await builder.build({ useV3: true }) + const builder = makeTxBuilder(builderConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: { + lovelace: 2_000_000n, + [TOKEN_UNIT]: 100n // Requires tokens! + } + }) + + const signBuilder = await builder.build({ + useV3: true, + availableUtxos: [utxo1, utxo2], + changeAddress: CHANGE_ADDRESS, + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() - + // Should automatically select utxo2 to cover the token requirement expect(tx.body.inputs.length).toBe(2) - + // Should have payment + change output expect(tx.body.outputs.length).toBe(2) - + // Payment output should have the requested amount const paymentOutput = tx.body.outputs[0] expect(paymentOutput.amount.coin).toBe(2_000_000n) - + // Change output should exist with remaining tokens (200 - 100 = 100) const _changeOutput = tx.body.outputs[1] // Verify the transaction is valid (coin selection worked correctly) @@ -378,7 +404,6 @@ describe("TxBuilder Re-selection Loop", () => { // ============================================================================ describe("Transaction Size Validation", () => { - it("should pass size check with same address (1 witness)", async () => { // Single address = 1 witness const utxos: Array = Array.from({ length: 5 }, (_, i) => @@ -390,24 +415,28 @@ describe("TxBuilder Re-selection Loop", () => { }) ) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(5_000_000n) - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(5_000_000n) // 5 ADA + }) - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: utxos, + protocolParameters: PROTOCOL_PARAMS + }) const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() - + const size = await Effect.runPromise(calculateTransactionSize(txWithFakeWitnesses)) expect(size).toBeLessThanOrEqual(PROTOCOL_PARAMS.maxTxSize) const validation = await assertFeeValid(txWithFakeWitnesses, PROTOCOL_PARAMS) - + // With automatic coin selection, builder picks 3 UTxOs (6M total) to cover 5M payment + fees // Strict expectations with deterministic values expect(size).toBe(366) // Exact transaction size with 1 witness, 3 inputs expect(validation.actualFee).toBe(171_485n) // Exact fee: 366*44 + 155_381 - + // Verify transaction structure const tx = await signBuilder.toTransaction() expect(tx.body.inputs.length).toBe(3) // Coin selection picked 3 UTxOs @@ -432,23 +461,27 @@ describe("TxBuilder Re-selection Loop", () => { lovelace: 5_000_000n }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo1, utxo2] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(6_000_000n) - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(6_000_000n) + }) - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2], + protocolParameters: PROTOCOL_PARAMS + }) const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() - + const size = await Effect.runPromise(calculateTransactionSize(txWithFakeWitnesses)) expect(size).toBeLessThanOrEqual(PROTOCOL_PARAMS.maxTxSize) const validation = await assertFeeValid(txWithFakeWitnesses, PROTOCOL_PARAMS) - + // Strict expectations with deterministic values expect(size).toBe(431) // Exact transaction size with 2 witnesses, 2 inputs expect(validation.actualFee).toBe(174_345n) // Exact fee: 431*44 + 155_381 - + // Verify transaction structure const tx = await signBuilder.toTransaction() expect(tx.body.inputs.length).toBe(2) @@ -463,10 +496,10 @@ describe("TxBuilder Re-selection Loop", () => { // With 150+ unique payment credentials, we'll need 150+ witnesses // Each witness ~130 bytes, so 150 witnesses = ~19.5KB of witnesses alone // Plus transaction body ~3-4KB = should exceed 16KB limit - + // Generate 200 unique addresses using KeyHash.arbitrary for payment credentials // This ensures payment key addresses (not script addresses) - const uniqueAddresses = FastCheck.sample(KeyHash.arbitrary, { seed: 42, numRuns: 200 }).map(keyHash => { + const uniqueAddresses = FastCheck.sample(KeyHash.arbitrary, { seed: 42, numRuns: 200 }).map((keyHash) => { // Create payment key address structure const addressStruct = AddressStructure.AddressStructure.make({ networkId: 0, // Testnet @@ -476,7 +509,7 @@ describe("TxBuilder Re-selection Loop", () => { // Convert to bech32 string return Schema.encodeSync(AddressStructure.FromBech32)(addressStruct) }) - + // Create 150 UTxOs with truly unique addresses // This will require 150 unique witnesses when selected const utxos: Array = uniqueAddresses.slice(0, 150).map((address, i) => { @@ -488,17 +521,23 @@ describe("TxBuilder Re-selection Loop", () => { }) }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) - .payToAddress({ - address: RECEIVER_ADDRESS, - // Request 280M to force selection of 140+ UTxOs (each 2M), which will create 140+ witnesses - // This will exceed the 16KB transaction size limit - assets: Assets.fromLovelace(280_000_000n) - }) + const builder = makeTxBuilder({ ...baseConfig }).payToAddress({ + address: RECEIVER_ADDRESS, + // Request 280M to force selection of 140+ UTxOs (each 2M), which will create 140+ witnesses + // This will exceed the 16KB transaction size limit + assets: Assets.fromLovelace(280_000_000n) + }) // Should throw error due to transaction size exceeding limit // With 140+ unique addresses selected, we get 140+ fake witnesses pushing size over 16384 bytes - await expect(builder.build({ useV3: true })).rejects.toThrow(/Transaction size.*16384|Build failed/) + await expect( + builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: utxos, + protocolParameters: PROTOCOL_PARAMS + }) + ).rejects.toThrow(/Transaction size.*16384|Build failed/) }) }) @@ -508,39 +547,37 @@ describe("TxBuilder Re-selection Loop", () => { describe("Multiple Reselection Attempts", () => { it("should trigger multiple reselection attempts with incremental coin selection", async () => { - // Create a mix of UTxO sizes - largest-first will pick bigger ones initially const utxos: Array = [ // Large UTxOs (selected first) createTestUtxo({ txHash: "a".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 1_500_000n }), createTestUtxo({ txHash: "b".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 1_200_000n }), - + // Medium UTxOs (for reselection) createTestUtxo({ txHash: "c".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 600_000n }), createTestUtxo({ txHash: "d".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 600_000n }), createTestUtxo({ txHash: "e".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 600_000n }), - + // Small UTxOs (for additional reselections if needed) createTestUtxo({ txHash: "f".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 400_000n }), createTestUtxo({ txHash: "g".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 400_000n }), createTestUtxo({ txHash: "h".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 400_000n }) ] - // Use makeTxBuilder with availableUtxos to enable coin selection - const builderConfig: TxBuilderConfig = { - ...baseConfig, - availableUtxos: utxos - } - - const builder = makeTxBuilder(builderConfig) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(2_500_000n) // 2.5 ADA payment - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(2_500_000n) // 2.5 ADA payment + }) // Build uses default largest-first algorithm // Use drainTo since the change will be small (33K < minUTxO) - const signBuilder = await builder.build({ drainTo: 0, useV3: true }) + const signBuilder = await builder.build({ + drainTo: 0, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: utxos, + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -556,10 +593,10 @@ describe("TxBuilder Re-selection Loop", () => { // 2 outputs: payment + change (change now sufficient for minUTxO) expect(tx.body.outputs.length).toBe(2) - + // Payment output is exactly 2.5M expect(tx.body.outputs[0].amount.coin).toBe(2_500_000n) - + // Change output: 3.3M total - 2.5M payment - 171K fee = 628K expect(tx.body.outputs[1].amount.coin).toBe(628_515n) }) @@ -567,7 +604,7 @@ describe("TxBuilder Re-selection Loop", () => { it("should trigger multiple reselection attempts with cascading fee increases", async () => { /** * Edge Case: Cascading Fee Increases - * + * * Create many tiny UTxOs where each selection barely covers the payment, * causing the algorithm to naturally trigger multiple reselection attempts * as fees cascade upward with more inputs. @@ -575,7 +612,7 @@ describe("TxBuilder Re-selection Loop", () => { // Create 20 small UTxOs, each with just enough to pass minUTxO // Using ~350K lovelace each (slightly above minUTxO of ~280K) - const tinyUtxos: Array = Array.from({ length: 20 }, (_, i) => + const tinyUtxos: Array = Array.from({ length: 20 }, (_, i) => createTestUtxo({ txHash: i.toString().padStart(64, "0"), outputIndex: 0, @@ -587,17 +624,18 @@ describe("TxBuilder Re-selection Loop", () => { // Request a payment that will require multiple UTxOs // Each UTxO contributes 350K, minus ~2K fee overhead = ~348K net // To get 3M payment, need ~9 UTxOs initially, but fee will increase - const builder = makeTxBuilder({ - ...baseConfig, - availableUtxos: tinyUtxos + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(3_000_000n) // 3 ADA }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(3_000_000n) // 3 ADA - }) // Build should succeed after multiple reselection attempts - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: tinyUtxos, + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -623,7 +661,6 @@ describe("TxBuilder Re-selection Loop", () => { }) it("should handle reselection with mixed-size UTxOs", async () => { - const utxos: Array = [ // First pass: Large UTxO insufficient by itself createTestUtxo({ txHash: "a".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 1_500_000n }), @@ -638,16 +675,17 @@ describe("TxBuilder Re-selection Loop", () => { createTestUtxo({ txHash: "f".repeat(64), outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 400_000n }) ] - const builder = makeTxBuilder({ - ...baseConfig, - availableUtxos: utxos + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(2_500_000n) // 2.5 ADA - requires reselection }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(2_500_000n) // 2.5 ADA - requires reselection - }) - const signBuilder = await builder.build({ useV3: true }) + const signBuilder = await builder.build({ + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: utxos, + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() const txWithFakeWitnesses = await signBuilder.toTransactionWithFakeWitnesses() @@ -658,7 +696,7 @@ describe("TxBuilder Re-selection Loop", () => { // Should need at least 2 inputs (1.5M + 0.8M + fee > 2.5M) expect(tx.body.inputs.length).toBeGreaterThanOrEqual(2) - + // Verify correct payment amount expect(tx.body.outputs[0].amount.coin).toBe(2_500_000n) }) @@ -673,13 +711,14 @@ describe("TxBuilder Reselection After Change", () => { maxTxSize: 16_384 } - const CHANGE_ADDRESS = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" - const RECEIVER_ADDRESS = "addr_test1qpw0djgj0x59ngrjvqthn7enhvruxnsavsw5th63la3mjel3tkc974sr23jmlzgq5zda4gtv8k9cy38756r9y3qgmkqqjz6aa7" + const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" + const RECEIVER_ADDRESS = + "addr_test1qpw0djgj0x59ngrjvqthn7enhvruxnsavsw5th63la3mjel3tkc974sr23jmlzgq5zda4gtv8k9cy38756r9y3qgmkqqjz6aa7" const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] + // No wallet/provider - using manual mode + // changeAddress and availableUtxos provided via build options } /** @@ -694,13 +733,18 @@ describe("TxBuilder Reselection After Change", () => { lovelace: 10_000_000n // 10 ADA }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [largeUtxo] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(5_000_000n) // 5 ADA payment - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(5_000_000n) // 5 ADA payment + }) - const signBuilder = await builder.build({ useStateMachine: true, useV3: true }) + const signBuilder = await builder.build({ + useStateMachine: true, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [largeUtxo], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() // Verify transaction structure @@ -728,17 +772,37 @@ describe("TxBuilder Reselection After Change", () => { * Verifies that UTxO reselection uses accurate fee calculation that includes change output size. */ it("should reselect UTxOs based on actual fee (after change creation)", async () => { - const utxo1: UTxO.UTxO = createTestUtxo({ txHash: "a", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 2_000_000n }) - const utxo2: UTxO.UTxO = createTestUtxo({ txHash: "b", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 2_000_000n }) - const utxo3: UTxO.UTxO = createTestUtxo({ txHash: "c", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 2_000_000n }) + const utxo1: UTxO.UTxO = createTestUtxo({ + txHash: "a", + outputIndex: 0, + address: CHANGE_ADDRESS, + lovelace: 2_000_000n + }) + const utxo2: UTxO.UTxO = createTestUtxo({ + txHash: "b", + outputIndex: 0, + address: CHANGE_ADDRESS, + lovelace: 2_000_000n + }) + const utxo3: UTxO.UTxO = createTestUtxo({ + txHash: "c", + outputIndex: 0, + address: CHANGE_ADDRESS, + lovelace: 2_000_000n + }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo1, utxo2, utxo3] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(3_500_000n) // Needs 2 UTxOs - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(3_500_000n) // Needs 2 UTxOs + }) - const signBuilder = await builder.build({ useStateMachine: true, useV3: true }) + const signBuilder = await builder.build({ + useStateMachine: true, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2, utxo3], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() // With coin selection: 2 UTxOs (4M) is sufficient for payment (3.5M) + fee (~170K) + change (~330K) @@ -761,7 +825,7 @@ describe("TxBuilder Reselection After Change", () => { it("should account for change output size when it contains native assets", async () => { const policyId = "a".repeat(56) const assetName = "544f4b454e" // "TOKEN" in hex - + const utxoWithAssets: UTxO.UTxO = createTestUtxo({ txHash: "c", outputIndex: 0, @@ -770,28 +834,33 @@ describe("TxBuilder Reselection After Change", () => { nativeAssets: { [`${policyId}${assetName}`]: 1000n } // Native assets increase change size }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxoWithAssets] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(3_000_000n) // Send only lovelace - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(3_000_000n) // Send only lovelace + }) - const signBuilder = await builder.build({ useStateMachine: true, useV3: true }) + const signBuilder = await builder.build({ + useStateMachine: true, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxoWithAssets], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() // Payment output: only lovelace expect(tx.body.outputs[0].amount.coin).toBe(3_000_000n) - + // Change output: remaining lovelace + ALL native assets const changeOutput = tx.body.outputs[1] - + // Verify native assets are in change (not burned) if (changeOutput.amount._tag === "WithAssets") { expect(changeOutput.amount.assets.size).toBeGreaterThan(0) } else { throw new Error("Expected change output to have native assets") } - + // Balance equation with native assets const expectedChange = 10_000_000n - 3_000_000n - tx.body.fee expect(changeOutput.amount.coin).toBe(expectedChange) @@ -804,16 +873,31 @@ describe("TxBuilder Reselection After Change", () => { * Verifies correct fee calculation when using multiple small UTxOs and change output affects transaction size. */ it("should handle case where change output affects fee calculation", async () => { - const utxo1: UTxO.UTxO = createTestUtxo({ txHash: "a", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 2_000_000n }) - const utxo2: UTxO.UTxO = createTestUtxo({ txHash: "b", outputIndex: 0, address: CHANGE_ADDRESS, lovelace: 2_000_000n }) + const utxo1: UTxO.UTxO = createTestUtxo({ + txHash: "a", + outputIndex: 0, + address: CHANGE_ADDRESS, + lovelace: 2_000_000n + }) + const utxo2: UTxO.UTxO = createTestUtxo({ + txHash: "b", + outputIndex: 0, + address: CHANGE_ADDRESS, + lovelace: 2_000_000n + }) - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo1, utxo2] }) - .payToAddress({ - address: RECEIVER_ADDRESS, - assets: Assets.fromLovelace(3_000_000n) - }) + const builder = makeTxBuilder(baseConfig).payToAddress({ + address: RECEIVER_ADDRESS, + assets: Assets.fromLovelace(3_000_000n) + }) - const signBuilder = await builder.build({ useStateMachine: true, useV3: true }) + const signBuilder = await builder.build({ + useStateMachine: true, + useV3: true, + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2], + protocolParameters: PROTOCOL_PARAMS + }) const tx = await signBuilder.toTransaction() // Should successfully build @@ -830,4 +914,4 @@ describe("TxBuilder Reselection After Change", () => { expect(changeOutput.amount.coin).toBeGreaterThan(0n) expect(changeOutput.amount.coin).toBeLessThan(1_000_000n) // Reasonable change amount }) -}) \ No newline at end of file +}) diff --git a/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts b/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts index 791478fa..1a05c7f9 100644 --- a/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts +++ b/packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts @@ -58,9 +58,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { ] const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: additionalUtxos // Available for re-selection }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -69,8 +66,11 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { }) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: additionalUtxos, // Available for re-selection useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, @@ -135,9 +135,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { ] const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: tinyUtxos // Available but won't be used }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -146,8 +143,11 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { }) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: tinyUtxos, // Available but won't be used useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, @@ -198,9 +198,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { } const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] // No more UTxOs available }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -211,8 +208,11 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { // Expect build to throw error await expect(async () => { await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [], // No more UTxOs available useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, @@ -239,9 +239,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { } const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -250,8 +247,11 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { }) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [], useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, @@ -282,9 +282,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { } const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -293,8 +290,11 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { }) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [], useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, @@ -322,9 +322,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { } const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -333,9 +330,12 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { }) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [], useV3: true, useStateMachine: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, @@ -364,9 +364,6 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { } const builder = makeTxBuilder({ - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] }) .collectFrom({ inputs: [initialUtxo] }) .payToAddress({ @@ -375,9 +372,12 @@ describe("TxBuilder: Unfrack Change Handling Integration", () => { }) const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [], useV3: true, useStateMachine: true, onInsufficientChange: "burn", + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, diff --git a/packages/evolution/test/TxBuilder.UnfrackDrain.test.ts b/packages/evolution/test/TxBuilder.UnfrackDrain.test.ts index fe4b1e39..5c9fa292 100644 --- a/packages/evolution/test/TxBuilder.UnfrackDrain.test.ts +++ b/packages/evolution/test/TxBuilder.UnfrackDrain.test.ts @@ -123,9 +123,6 @@ const createSimpleAdaWallet = (): Array => [ describe("TxBuilder Unfrack + DrainTo Integration", () => { const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: DESTINATION_ADDRESS, - availableUtxos: [] } // ========================================================================== @@ -138,7 +135,7 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Arrange: Simple ADA-only wallet const utxos = createSimpleAdaWallet() - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const builder = makeTxBuilder(baseConfig) .payToAddress({ address: DESTINATION_ADDRESS, assets: Assets.fromLovelace(1_000_000n) // 1 ADA minimum payment @@ -146,8 +143,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain to output 0 with ADA subdivision const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 100_000_000n, // 100 ADA @@ -193,7 +193,7 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { } ] - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: utxos }) + const builder = makeTxBuilder(baseConfig) .payToAddress({ address: DESTINATION_ADDRESS, assets: Assets.fromLovelace(1_000_000n) @@ -201,8 +201,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with subdivision threshold of 100 ADA (total is 80 ADA, below threshold) const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 100_000_000n // 100 ADA threshold @@ -247,8 +250,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with token bundling (no isolation or grouping) const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5 // Bundle tokens in groups of 5 @@ -287,8 +293,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with fungible isolation const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5, @@ -328,8 +337,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with NFT policy grouping const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5, @@ -369,8 +381,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with full Unfrack.It optimization const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5, @@ -424,9 +439,12 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain without any unfracking (standard consolidation) const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, // No unfrack options + protocolParameters: PROTOCOL_PARAMS, useStateMachine: true }) @@ -462,8 +480,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with empty token options (only ADA subdivision active) const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 10 // Default value, but no tokens present @@ -515,8 +536,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain with subdivision threshold much higher than leftover const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 100_000_000n // 100 ADA (leftover is ~5 ADA) @@ -557,8 +581,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Drain to first output with unfracking const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, // Drain into first payment output + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5 @@ -608,8 +635,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Subdividing by [25, 25, 25, 25] would create 4 outputs of ~0.46 ADA each // which is BELOW minimum UTxO requirement of ~1.72 ADA const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { ada: { subdivideThreshold: 500_000n, // 0.5 ADA (very low threshold) @@ -655,8 +685,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Full wallet optimization const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 10, // Larger bundles for efficiency @@ -701,8 +734,11 @@ describe("TxBuilder Unfrack + DrainTo Integration", () => { // Act: Migrate everything to destination with optimization const signBuilder = await builder.build({ + changeAddress: SOURCE_ADDRESS, + availableUtxos: utxos, useV3: true, drainTo: 0, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5, diff --git a/packages/evolution/test/TxBuilder.UnfrackMinUTxO.test.ts b/packages/evolution/test/TxBuilder.UnfrackMinUTxO.test.ts index 09b330cc..40f524c4 100644 --- a/packages/evolution/test/TxBuilder.UnfrackMinUTxO.test.ts +++ b/packages/evolution/test/TxBuilder.UnfrackMinUTxO.test.ts @@ -48,9 +48,6 @@ const ASSET_NAME_HEX = "544f4b454e" // "TOKEN" in hex describe.concurrent("TxBuilder - Unfrack MinUTxO", () => { const baseConfig: TxBuilderConfig = { - protocolParameters: PROTOCOL_PARAMS, - changeAddress: CHANGE_ADDRESS, - availableUtxos: [] } /** @@ -82,18 +79,19 @@ describe.concurrent("TxBuilder - Unfrack MinUTxO", () => { } } - const builder = makeTxBuilder({ - ...baseConfig, - availableUtxos: [utxo1, utxo2] - }) + const builder = makeTxBuilder(baseConfig) .payToAddress({ address: RECIPIENT_ADDRESS, assets: Assets.fromLovelace(2_000_000n) // 2.0 ADA only }) // Act: Build transaction with unfrack enabled - const signBuilder = await builder.build({ useV3: true, + const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2], + useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 10 // All 5 tokens fit in one bundle @@ -182,18 +180,19 @@ describe.concurrent("TxBuilder - Unfrack MinUTxO", () => { } } - const builder = makeTxBuilder({ - ...baseConfig, - availableUtxos: [utxo1, utxo2, utxo3] - }) + const builder = makeTxBuilder(baseConfig) .payToAddress({ address: RECIPIENT_ADDRESS, assets: Assets.fromLovelace(2_000_000n) }) // Act: Build transaction with unfrack (bundleSize=5 → 3 bundles for 15 tokens) - const signBuilder = await builder.build({ useV3: true, + const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo1, utxo2, utxo3], + useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, unfrack: { tokens: { bundleSize: 5 // 15 tokens → 3 bundles @@ -247,15 +246,19 @@ describe.concurrent("TxBuilder - Unfrack MinUTxO", () => { } } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }) + const builder = makeTxBuilder(baseConfig) .payToAddress({ address: RECIPIENT_ADDRESS, assets: Assets.fromLovelace(2_000_000n) }) // Build with drainTo option - const signBuilder = await builder.build({ useV3: true, + const signBuilder = await builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], + useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, drainTo: 0 // Request drain into first output }) @@ -291,7 +294,7 @@ describe.concurrent("TxBuilder - Unfrack MinUTxO", () => { } } - const builder = makeTxBuilder({ ...baseConfig, availableUtxos: [utxo] }) + const builder = makeTxBuilder(baseConfig) .payToAddress({ address: RECIPIENT_ADDRESS, assets: Assets.fromLovelace(2_000_000n) @@ -299,8 +302,12 @@ describe.concurrent("TxBuilder - Unfrack MinUTxO", () => { // Try with burnAsFee - should fail because of native assets await expect( - builder.build({ useV3: true, + builder.build({ + changeAddress: CHANGE_ADDRESS, + availableUtxos: [utxo], + useV3: true, useStateMachine: true, + protocolParameters: PROTOCOL_PARAMS, onInsufficientChange: "burn" }) ).rejects.toThrow() // Should error about native assets diff --git a/packages/evolution/test/WalletFromSeed.test.ts b/packages/evolution/test/WalletFromSeed.test.ts index 06bb4a12..ceb65a3a 100644 --- a/packages/evolution/test/WalletFromSeed.test.ts +++ b/packages/evolution/test/WalletFromSeed.test.ts @@ -1,117 +1,99 @@ -import { Either } from "effect" -import { expect, test } from "vitest" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" import { walletFromSeed } from "../src/sdk/wallet/Derivation.js" const seedPhrase = "zebra short room flavor rival capital fortune hip profit trust melody office depend adapt visa cycle february link tornado whisper physical kiwi film voyage" -test("WalletFromSeed - Defaults options", () => { - const expectedFromSeed = { - address: "addr1q98wl3hnya9l94rt58ky533deyqe9t8zz5n9su26k8e5g23yar4q0adtaax9q9g0kphpv2ws7vxqwu6ln6pqx7j29nfqsfy9mg", - rewardAddress: "stake1uyjw36s87k477nzsz58mqmsk98g0xrq8wd0eaqsr0f9ze5s48wtl9", - paymentKey: - "ed25519e_sk1krszcw3ujfs3qnsjwl6wynw7dwudgnq69w9lrrtdf46yqnd25dgv4f5ttaqxr2v6n6azee489c7mryudvhu8n4x4tcvd5hvhtwswsuc4s4c2d", - stakeKey: - "ed25519e_sk19q4d6fguvncszk6f46fvvep5y5w3877y77t3n3dc446wgja25dg968hm8jxkc9d7p982uls6k8uq0srs69e44lay43hxmdx4nc3rttsn0h2f5" - } - const result1 = walletFromSeed(seedPhrase, { - addressType: "Base", - accountIndex: 0, - network: "Mainnet" - }) - expect(Either.isRight(result1)).toBe(true) - if (Either.isRight(result1)) { - expect(expectedFromSeed).toStrictEqual(result1.right) - } +describe("WalletFromSeed", () => { + it.effect("Defaults options", () => + Effect.gen(function* () { + const result1 = yield* walletFromSeed(seedPhrase, { + addressType: "Base", + accountIndex: 0, + network: "Mainnet" + }) + + expect(result1.address).toBe("addr1q98wl3hnya9l94rt58ky533deyqe9t8zz5n9su26k8e5g23yar4q0adtaax9q9g0kphpv2ws7vxqwu6ln6pqx7j29nfqsfy9mg") + expect(result1.rewardAddress).toBe("stake1uyjw36s87k477nzsz58mqmsk98g0xrq8wd0eaqsr0f9ze5s48wtl9") + expect(result1.paymentKey).toBe("ed25519e_sk1krszcw3ujfs3qnsjwl6wynw7dwudgnq69w9lrrtdf46yqnd25dgv4f5ttaqxr2v6n6azee489c7mryudvhu8n4x4tcvd5hvhtwswsuc4s4c2d") + expect(result1.stakeKey).toBe("ed25519e_sk19q4d6fguvncszk6f46fvvep5y5w3877y77t3n3dc446wgja25dg968hm8jxkc9d7p982uls6k8uq0srs69e44lay43hxmdx4nc3rttsn0h2f5") - const result2 = walletFromSeed(seedPhrase) - expect(Either.isRight(result2)).toBe(true) - if (Either.isRight(result2)) { - expect(expectedFromSeed).toStrictEqual(result2.right) - } -}) + const result2 = yield* walletFromSeed(seedPhrase) + expect(result2.address).toBe(result1.address) + expect(result2.rewardAddress).toBe(result1.rewardAddress) + expect(result2.paymentKey).toBe(result1.paymentKey) + expect(result2.stakeKey).toBe(result1.stakeKey) + }) + ) -test("WalletFromSeed - accountIndex 1", () => { - const expectedFromSeed = { - address: "addr1q8833yrnksyq3v3u582g8pkzzdmg9yge7lftvu8lj6lakmp7e5x8vl3sqdtxra9z9p95k27kx2njgqux86d5mtfc2t8sa7jy78", - rewardAddress: "stake1uylv6rrk0ccqx4np7j3zsj6t90tr9feyqwrrax6d45u99nce2rkhr", - paymentKey: - "ed25519e_sk1tzqvdwc8kr9zk4fmlwhexzpgcgx8t35zls2ckeswehpdsja25dg9j998sp9s2xy0aeyrdquhpljwfgghz9e78wqux8xj9t2p8z59ahc75nyyr", - stakeKey: - "ed25519e_sk1trauywg7p9x2hg3jgaw2adeyg5ujhax4jfd6exs6hpzakn925dggyvhgrh8kwc9h9n7nh75nwhge9gyxg7vavcwk7mq3r2t03664drcrdegzx" - } - const result1 = walletFromSeed(seedPhrase, { - addressType: "Base", - accountIndex: 1, - network: "Mainnet" - }) - expect(Either.isRight(result1)).toBe(true) - if (Either.isRight(result1)) { - expect(expectedFromSeed).toStrictEqual(result1.right) - } + it.effect("accountIndex 1", () => + Effect.gen(function* () { + const result1 = yield* walletFromSeed(seedPhrase, { + addressType: "Base", + accountIndex: 1, + network: "Mainnet" + }) + + expect(result1.address).toBe("addr1q8833yrnksyq3v3u582g8pkzzdmg9yge7lftvu8lj6lakmp7e5x8vl3sqdtxra9z9p95k27kx2njgqux86d5mtfc2t8sa7jy78") + expect(result1.rewardAddress).toBe("stake1uylv6rrk0ccqx4np7j3zsj6t90tr9feyqwrrax6d45u99nce2rkhr") + expect(result1.paymentKey).toBe("ed25519e_sk1tzqvdwc8kr9zk4fmlwhexzpgcgx8t35zls2ckeswehpdsja25dg9j998sp9s2xy0aeyrdquhpljwfgghz9e78wqux8xj9t2p8z59ahc75nyyr") + expect(result1.stakeKey).toBe("ed25519e_sk1trauywg7p9x2hg3jgaw2adeyg5ujhax4jfd6exs6hpzakn925dggyvhgrh8kwc9h9n7nh75nwhge9gyxg7vavcwk7mq3r2t03664drcrdegzx") - const result2 = walletFromSeed(seedPhrase, { - accountIndex: 1 - }) - expect(Either.isRight(result2)).toBe(true) - if (Either.isRight(result2)) { - expect(expectedFromSeed).toStrictEqual(result2.right) - } -}) + const result2 = yield* walletFromSeed(seedPhrase, { + accountIndex: 1 + }) + expect(result2.address).toBe(result1.address) + expect(result2.rewardAddress).toBe(result1.rewardAddress) + expect(result2.paymentKey).toBe(result1.paymentKey) + expect(result2.stakeKey).toBe(result1.stakeKey) + }) + ) -test("WalletFromSeed - Custom Network", () => { - const expectedFromSeed = { - address: - "addr_test1qp8wl3hnya9l94rt58ky533deyqe9t8zz5n9su26k8e5g23yar4q0adtaax9q9g0kphpv2ws7vxqwu6ln6pqx7j29nfqnle9hh", - rewardAddress: "stake_test1uqjw36s87k477nzsz58mqmsk98g0xrq8wd0eaqsr0f9ze5sjdyfmc", - paymentKey: - "ed25519e_sk1krszcw3ujfs3qnsjwl6wynw7dwudgnq69w9lrrtdf46yqnd25dgv4f5ttaqxr2v6n6azee489c7mryudvhu8n4x4tcvd5hvhtwswsuc4s4c2d", - stakeKey: - "ed25519e_sk19q4d6fguvncszk6f46fvvep5y5w3877y77t3n3dc446wgja25dg968hm8jxkc9d7p982uls6k8uq0srs69e44lay43hxmdx4nc3rttsn0h2f5" - } - const result1 = walletFromSeed(seedPhrase, { - addressType: "Base", - accountIndex: 0, - network: "Custom" - }) - expect(Either.isRight(result1)).toBe(true) - if (Either.isRight(result1)) { - expect(expectedFromSeed).toStrictEqual(result1.right) - } + it.effect("Custom Network", () => + Effect.gen(function* () { + const result1 = yield* walletFromSeed(seedPhrase, { + addressType: "Base", + accountIndex: 0, + network: "Custom" + }) + + expect(result1.address).toBe("addr_test1qp8wl3hnya9l94rt58ky533deyqe9t8zz5n9su26k8e5g23yar4q0adtaax9q9g0kphpv2ws7vxqwu6ln6pqx7j29nfqnle9hh") + expect(result1.rewardAddress).toBe("stake_test1uqjw36s87k477nzsz58mqmsk98g0xrq8wd0eaqsr0f9ze5sjdyfmc") + expect(result1.paymentKey).toBe("ed25519e_sk1krszcw3ujfs3qnsjwl6wynw7dwudgnq69w9lrrtdf46yqnd25dgv4f5ttaqxr2v6n6azee489c7mryudvhu8n4x4tcvd5hvhtwswsuc4s4c2d") + expect(result1.stakeKey).toBe("ed25519e_sk19q4d6fguvncszk6f46fvvep5y5w3877y77t3n3dc446wgja25dg968hm8jxkc9d7p982uls6k8uq0srs69e44lay43hxmdx4nc3rttsn0h2f5") - const result2 = walletFromSeed(seedPhrase, { - network: "Custom" - }) - expect(Either.isRight(result2)).toBe(true) - if (Either.isRight(result2)) { - expect(expectedFromSeed).toStrictEqual(result2.right) - } -}) + const result2 = yield* walletFromSeed(seedPhrase, { + network: "Custom" + }) + expect(result2.address).toBe(result1.address) + expect(result2.rewardAddress).toBe(result1.rewardAddress) + expect(result2.paymentKey).toBe(result1.paymentKey) + expect(result2.stakeKey).toBe(result1.stakeKey) + }) + ) -test("WalletFromSeed - Address Enterprise", () => { - const expectedFromSeed = { - address: "addr1v98wl3hnya9l94rt58ky533deyqe9t8zz5n9su26k8e5g2srcn4hd", - rewardAddress: undefined, - paymentKey: - "ed25519e_sk1krszcw3ujfs3qnsjwl6wynw7dwudgnq69w9lrrtdf46yqnd25dgv4f5ttaqxr2v6n6azee489c7mryudvhu8n4x4tcvd5hvhtwswsuc4s4c2d", - stakeKey: undefined - } - const result1 = walletFromSeed(seedPhrase, { - addressType: "Enterprise", - accountIndex: 0, - network: "Mainnet" - }) - expect(Either.isRight(result1)).toBe(true) - if (Either.isRight(result1)) { - expect(expectedFromSeed).toStrictEqual(result1.right) - } + it.effect("Address Enterprise", () => + Effect.gen(function* () { + const result1 = yield* walletFromSeed(seedPhrase, { + addressType: "Enterprise", + accountIndex: 0, + network: "Mainnet" + }) + + expect(result1.address).toBe("addr1v98wl3hnya9l94rt58ky533deyqe9t8zz5n9su26k8e5g2srcn4hd") + expect(result1.rewardAddress).toBeUndefined() + expect(result1.paymentKey).toBe("ed25519e_sk1krszcw3ujfs3qnsjwl6wynw7dwudgnq69w9lrrtdf46yqnd25dgv4f5ttaqxr2v6n6azee489c7mryudvhu8n4x4tcvd5hvhtwswsuc4s4c2d") + expect(result1.stakeKey).toBeUndefined() - const result2 = walletFromSeed(seedPhrase, { - addressType: "Enterprise" - }) - expect(Either.isRight(result2)).toBe(true) - if (Either.isRight(result2)) { - expect(expectedFromSeed).toStrictEqual(result2.right) - } + const result2 = yield* walletFromSeed(seedPhrase, { + addressType: "Enterprise" + }) + expect(result2.address).toBe(result1.address) + expect(result2.rewardAddress).toBeUndefined() + expect(result2.paymentKey).toBe(result1.paymentKey) + expect(result2.stakeKey).toBeUndefined() + }) + ) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f759910..5e8cf610 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@effect/platform-node': specifier: ^0.96.0 version: 0.96.0(@effect/cluster@0.48.2(@effect/platform@0.90.6(effect@3.17.9))(@effect/rpc@0.69.1(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(@effect/sql@0.44.2(@effect/experimental@0.54.6(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(@effect/workflow@0.9.2(@effect/platform@0.90.6(effect@3.17.9))(@effect/rpc@0.69.1(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(effect@3.17.9))(effect@3.17.9))(@effect/platform@0.90.6(effect@3.17.9))(@effect/rpc@0.69.1(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(@effect/sql@0.44.2(@effect/experimental@0.54.6(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(@effect/platform@0.90.6(effect@3.17.9))(effect@3.17.9))(effect@3.17.9) + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 '@noble/hashes': specifier: ^1.8.0 version: 1.8.0 @@ -180,9 +183,6 @@ importers: effect: specifier: ^3.17.9 version: 3.17.9 - libsodium-wrappers-sumo: - specifier: ^0.7.15 - version: 0.7.15 devDependencies: '@dcspark/cardano-multiplatform-lib-nodejs': specifier: ^6.2.0 @@ -190,9 +190,6 @@ importers: '@types/dockerode': specifier: ^3.3.43 version: 3.3.43 - '@types/libsodium-wrappers-sumo': - specifier: ^0.7.8 - version: 0.7.8 tinybench: specifier: ^5.0.0 version: 5.0.0 @@ -1003,10 +1000,18 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1810,12 +1815,6 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/libsodium-wrappers-sumo@0.7.8': - resolution: {integrity: sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw==} - - '@types/libsodium-wrappers@0.7.14': - resolution: {integrity: sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3669,12 +3668,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libsodium-sumo@0.7.15: - resolution: {integrity: sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw==} - - libsodium-wrappers-sumo@0.7.15: - resolution: {integrity: sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==} - lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -6322,8 +6315,14 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7088,12 +7087,6 @@ snapshots: '@types/json5@0.0.29': {} - '@types/libsodium-wrappers-sumo@0.7.8': - dependencies: - '@types/libsodium-wrappers': 0.7.14 - - '@types/libsodium-wrappers@0.7.14': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -9261,12 +9254,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libsodium-sumo@0.7.15: {} - - libsodium-wrappers-sumo@0.7.15: - dependencies: - libsodium-sumo: 0.7.15 - lightningcss-darwin-arm64@1.30.1: optional: true