From 1442840b5d401101801bed104fe2fa6e24308fd4 Mon Sep 17 00:00:00 2001 From: hade Date: Tue, 9 Sep 2025 16:49:06 +0700 Subject: [PATCH] feat(txbuilder): add builders --- .../src/builders/CertificateBuilder.ts | 269 +++++ .../evolution/src/builders/InputBuilder.ts | 248 +++++ .../evolution/src/builders/MintBuilder.ts | 93 ++ .../evolution/src/builders/OutputBuilder.ts | 322 ++++++ .../evolution/src/builders/ProposalBuilder.ts | 239 +++++ .../evolution/src/builders/RedeemerBuilder.ts | 432 ++++++++ packages/evolution/src/builders/TxBuilder.ts | 986 ++++++++++++++++++ .../evolution/src/builders/VoteBuilder.ts | 316 ++++++ .../src/builders/WithdrawalBuilder.ts | 195 ++++ .../evolution/src/builders/WitnessBuilder.ts | 242 +++++ packages/evolution/src/builders/index.ts | 38 + .../evolution/src/builders/utils/MinAda.ts | 178 ++++ .../evolution/src/builders/utils/index.ts | 1 + 13 files changed, 3559 insertions(+) create mode 100644 packages/evolution/src/builders/CertificateBuilder.ts create mode 100644 packages/evolution/src/builders/InputBuilder.ts create mode 100644 packages/evolution/src/builders/MintBuilder.ts create mode 100644 packages/evolution/src/builders/OutputBuilder.ts create mode 100644 packages/evolution/src/builders/ProposalBuilder.ts create mode 100644 packages/evolution/src/builders/RedeemerBuilder.ts create mode 100644 packages/evolution/src/builders/TxBuilder.ts create mode 100644 packages/evolution/src/builders/VoteBuilder.ts create mode 100644 packages/evolution/src/builders/WithdrawalBuilder.ts create mode 100644 packages/evolution/src/builders/WitnessBuilder.ts create mode 100644 packages/evolution/src/builders/index.ts create mode 100644 packages/evolution/src/builders/utils/MinAda.ts create mode 100644 packages/evolution/src/builders/utils/index.ts diff --git a/packages/evolution/src/builders/CertificateBuilder.ts b/packages/evolution/src/builders/CertificateBuilder.ts new file mode 100644 index 00000000..8f01fa65 --- /dev/null +++ b/packages/evolution/src/builders/CertificateBuilder.ts @@ -0,0 +1,269 @@ +import { Data, Effect as Eff } from "effect" + +import type * as Certificate from "../core/Certificate.js" +import type * as Credential from "../core/Credential.js" +import * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import type * as PoolKeyHash from "../core/PoolKeyHash.js" +import * as ScriptHash from "../core/ScriptHash.js" +import type { NativeScriptWitnessInfo, PartialPlutusWitness } from "./WitnessBuilder.js" +import { InputAggregateWitnessData, PlutusScriptWitness, RequiredWitnessSet } from "./WitnessBuilder.js" + +/** + * Error class for CertificateBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class CertificateBuilderError extends Data.TaggedError("CertificateBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Calculates required witnesses for a certificate + * + * @since 2.0.0 + * @category utils + */ +export function certRequiredWits(cert: Certificate.Certificate, requiredWitnesses: RequiredWitnessSet): void { + switch (cert._tag) { + case "StakeRegistration": + // Stake key registrations do not require a witness + break + + case "StakeDeregistration": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "StakeDelegation": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "PoolRegistration": + cert.poolParams.poolOwners.forEach((owner) => { + requiredWitnesses.addVkeyKeyHash(owner) // owner is already KeyHash + }) + requiredWitnesses.addVkeyKeyHash(poolKeyHashToKeyHash(cert.poolParams.operator)) // operator is PoolKeyHash + break + + case "PoolRetirement": + requiredWitnesses.addVkeyKeyHash(poolKeyHashToKeyHash(cert.poolKeyHash)) + break + + case "RegCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "UnregCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "VoteDelegCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "StakeVoteDelegCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "StakeRegDelegCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "VoteRegDelegCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "StakeVoteRegDelegCert": + addCredentialWitness(cert.stakeCredential, requiredWitnesses) + break + + case "AuthCommitteeHotCert": + addCredentialWitness(cert.committeeColdCredential, requiredWitnesses) + break + + case "ResignCommitteeColdCert": + addCredentialWitness(cert.committeeColdCredential, requiredWitnesses) + break + + case "RegDrepCert": + addCredentialWitness(cert.drepCredential, requiredWitnesses) + break + + case "UnregDrepCert": + addCredentialWitness(cert.drepCredential, requiredWitnesses) + break + + case "UpdateDrepCert": + addCredentialWitness(cert.drepCredential, requiredWitnesses) + break + } +} + +function addCredentialWitness(credential: Credential.CredentialSchema, requiredWitnesses: RequiredWitnessSet): void { + switch (credential._tag) { + case "KeyHash": + requiredWitnesses.addVkeyKeyHash(credential) + break + case "ScriptHash": + requiredWitnesses.addScriptHash(credential) + break + } +} + +function poolKeyHashToKeyHash(poolKeyHash: PoolKeyHash.PoolKeyHash): KeyHash.KeyHash { + // Both PoolKeyHash and KeyHash are based on Hash28, so we can convert by extracting the hash + return KeyHash.make({ hash: poolKeyHash.hash }) +} + +/** + * Result of building a certificate + * + * @since 2.0.0 + * @category model + */ +export interface CertificateBuilderResult { + cert: Certificate.Certificate + aggregateWitness?: InputAggregateWitnessData + requiredWits: RequiredWitnessSet +} + +/** + * Builder for a single certificate + * + * @since 2.0.0 + * @category builders + */ +export class SingleCertificateBuilder { + constructor(public readonly cert: Certificate.Certificate) {} + + static new(cert: Certificate.Certificate): SingleCertificateBuilder { + return new SingleCertificateBuilder(cert) + } + + skipWitness(): CertificateBuilderResult { + const requiredWits = RequiredWitnessSet.default() + certRequiredWits(this.cert, requiredWits) + + return { + cert: this.cert, + aggregateWitness: undefined, + requiredWits + } + } + + paymentKey(): Eff.Effect { + return Eff.gen( + function* (this: SingleCertificateBuilder) { + const requiredWits = RequiredWitnessSet.default() + certRequiredWits(this.cert, requiredWits) + + if (requiredWits.scripts.length > 0) { + return yield* Eff.fail( + new CertificateBuilderError({ + message: `Certificate contains script. Expected public key hash.` + }) + ) + } + + return { + cert: this.cert, + aggregateWitness: undefined, + requiredWits + } + }.bind(this) + ) + } + + nativeScript( + nativeScript: NativeScripts.NativeScript, + witnessInfo: NativeScriptWitnessInfo + ): Eff.Effect { + return Eff.gen( + function* (this: SingleCertificateBuilder) { + const requiredWits = RequiredWitnessSet.default() + certRequiredWits(this.cert, requiredWits) + const requiredWitsLeft = structuredClone(requiredWits) + + const scriptHash = ScriptHash.fromScript(nativeScript) + + // Check if the script is actually required + const contains = requiredWitsLeft.scripts.some((h) => ScriptHash.equals(h, scriptHash)) + + // Remove the script hash + const filteredScripts = requiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const mutableRequiredWitsLeft = { ...requiredWitsLeft, scripts: filteredScripts } + + if (mutableRequiredWitsLeft.scripts.length > 0) { + return yield* Eff.fail( + new CertificateBuilderError({ + message: "Missing the following witnesses for the certificate", + cause: mutableRequiredWitsLeft + }) + ) + } + + return { + cert: this.cert, + aggregateWitness: contains ? InputAggregateWitnessData.nativeScript(nativeScript, witnessInfo) : undefined, + requiredWits + } + }.bind(this) + ) + } + + plutusScript( + partialWitness: PartialPlutusWitness, + requiredSigners: Array + ): Eff.Effect { + return Eff.gen( + function* (this: SingleCertificateBuilder) { + const requiredWits = RequiredWitnessSet.default() + requiredSigners.forEach((signer) => requiredWits.addVkeyKeyHash(signer)) + certRequiredWits(this.cert, requiredWits) + const requiredWitsLeft = structuredClone(requiredWits) + + // Clear vkeys as we don't know which ones will be used + const mutableRequiredWitsLeft = { ...requiredWitsLeft, vkeys: [] } + + const scriptHash = PlutusScriptWitness.hash(partialWitness.scriptWitness) + + // Check if the script is actually required + const contains = requiredWitsLeft.scripts.some((h) => ScriptHash.equals(h, scriptHash)) + + // Remove the script hash + const filteredPlutusScripts = mutableRequiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const finalRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: mutableRequiredWitsLeft.vkeys, + bootstraps: mutableRequiredWitsLeft.bootstraps, + scripts: filteredPlutusScripts, + plutusData: mutableRequiredWitsLeft.plutusData, + redeemers: mutableRequiredWitsLeft.redeemers, + scriptRefs: mutableRequiredWitsLeft.scriptRefs + }) + + if (finalRequiredWitsLeft.len() > 0) { + return yield* Eff.fail( + new CertificateBuilderError({ + message: "Missing the following witnesses for the certificate", + cause: finalRequiredWitsLeft + }) + ) + } + + return { + cert: this.cert, + aggregateWitness: contains + ? InputAggregateWitnessData.plutusScript( + partialWitness, + requiredSigners, + undefined // No datum for certificates + ) + : undefined, + requiredWits + } + }.bind(this) + ) + } +} diff --git a/packages/evolution/src/builders/InputBuilder.ts b/packages/evolution/src/builders/InputBuilder.ts new file mode 100644 index 00000000..6240f673 --- /dev/null +++ b/packages/evolution/src/builders/InputBuilder.ts @@ -0,0 +1,248 @@ +import { Data, Effect as Eff } from "effect" + +import type * as Credential from "../core/Credential.js" +import type * as PlutusData from "../core/Data.js" +import * as DatumOption from "../core/DatumOption.js" +import type * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import * as ScriptHash from "../core/ScriptHash.js" +import type * as TransactionInput from "../core/TransactionInput.js" +import type * as TransactionOutput from "../core/TransactionOutput.js" +import { hashPlutusData } from "../utils/Hash.js" +import type { NativeScriptWitnessInfo, PartialPlutusWitness } from "./WitnessBuilder.js" +import { InputAggregateWitnessData, PlutusScriptWitness, RequiredWitnessSet } from "./WitnessBuilder.js" + +/** + * Error class for InputBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class InputBuilderError extends Data.TaggedError("InputBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Calculates required witnesses for a transaction output + * + * @since 2.0.0 + * @category utils + */ +export function inputRequiredWits( + utxoInfo: TransactionOutput.TransactionOutput, + requiredWitnesses: RequiredWitnessSet +): void { + const address = utxoInfo.address + + // Extract payment credential based on address type + // TransactionOutput only supports BaseAddress and EnterpriseAddress + let paymentCred: Credential.CredentialSchema | undefined + switch (address._tag) { + case "BaseAddress": + paymentCred = address.paymentCredential + break + case "EnterpriseAddress": + paymentCred = address.paymentCredential + break + } + + if (paymentCred) { + switch (paymentCred._tag) { + case "KeyHash": + requiredWitnesses.addVkeyKeyHash(paymentCred) + break + case "ScriptHash": + requiredWitnesses.addScriptHash(paymentCred) + // Check for datum hash in output + if (utxoInfo._tag === "ShelleyTransactionOutput" && utxoInfo.datumHash) { + requiredWitnesses.addPlutusDataHash(utxoInfo.datumHash) + } else if (utxoInfo._tag === "BabbageTransactionOutput" && utxoInfo.datumOption) { + if (utxoInfo.datumOption._tag === "DatumHash") { + requiredWitnesses.addPlutusDataHash(utxoInfo.datumOption) + } + } + break + } + } +} + +/** + * Result of building a transaction input + * + * @since 2.0.0 + * @category model + */ +export interface InputBuilderResult { + input: TransactionInput.TransactionInput + utxoInfo: TransactionOutput.TransactionOutput + aggregateWitness?: InputAggregateWitnessData + requiredWits: RequiredWitnessSet +} + +/** + * Builder for a single transaction input + * + * @since 2.0.0 + * @category builders + */ +export class SingleInputBuilder { + constructor( + public readonly input: TransactionInput.TransactionInput, + public readonly utxoInfo: TransactionOutput.TransactionOutput + ) {} + + static new( + input: TransactionInput.TransactionInput, + utxoInfo: TransactionOutput.TransactionOutput + ): SingleInputBuilder { + return new SingleInputBuilder(input, utxoInfo) + } + + paymentKey(): Eff.Effect { + return Eff.gen( + function* (this: SingleInputBuilder) { + const requiredWits = RequiredWitnessSet.default() + inputRequiredWits(this.utxoInfo, requiredWits) + + // Check that no scripts are required + if (requiredWits.scripts.length > 0) { + return yield* Eff.fail( + new InputBuilderError({ + message: `UTXO address was not a payment key: ${this.utxoInfo.address}` + }) + ) + } + + return { + input: this.input, + utxoInfo: this.utxoInfo, + aggregateWitness: undefined, + requiredWits + } + }.bind(this) + ) + } + + nativeScript( + nativeScript: NativeScripts.NativeScript, + witnessInfo: NativeScriptWitnessInfo + ): Eff.Effect { + return Eff.gen( + function* (this: SingleInputBuilder) { + const requiredWits = RequiredWitnessSet.default() + inputRequiredWits(this.utxoInfo, requiredWits) + const requiredWitsLeft = structuredClone(requiredWits) + + const scriptHash = ScriptHash.fromScript(nativeScript) + + // Remove the script hash from required witnesses + const filteredScripts = requiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const mutableRequiredWitsLeft = { ...requiredWitsLeft, scripts: filteredScripts } + + if (mutableRequiredWitsLeft.scripts.length > 0) { + return yield* Eff.fail( + new InputBuilderError({ + message: `Missing the following witnesses for the input`, + cause: mutableRequiredWitsLeft + }) + ) + } + + return { + input: this.input, + utxoInfo: this.utxoInfo, + aggregateWitness: InputAggregateWitnessData.nativeScript(nativeScript, witnessInfo), + requiredWits + } + }.bind(this) + ) + } + + plutusScript( + partialWitness: PartialPlutusWitness, + requiredSigners: Array, + datum: PlutusData.Data + ): Eff.Effect { + return this.plutusScriptInner(partialWitness, requiredSigners, datum) + } + + plutusScriptInlineDatum( + partialWitness: PartialPlutusWitness, + requiredSigners: Array + ): Eff.Effect { + return this.plutusScriptInner(partialWitness, requiredSigners, undefined) + } + + private plutusScriptInner( + partialWitness: PartialPlutusWitness, + requiredSigners: Array, + datum?: PlutusData.Data + ): Eff.Effect { + return Eff.gen( + function* (this: SingleInputBuilder) { + const requiredWits = RequiredWitnessSet.default() + + // Add required signers + requiredSigners.forEach((signer) => requiredWits.addVkeyKeyHash(signer)) + + inputRequiredWits(this.utxoInfo, requiredWits) + const requiredWitsLeft = structuredClone(requiredWits) + + // Clear vkeys as we don't know which ones will be used + const clearedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: [], // Cleared + bootstraps: requiredWitsLeft.bootstraps, + scripts: requiredWitsLeft.scripts, + plutusData: requiredWitsLeft.plutusData, + redeemers: requiredWitsLeft.redeemers, + scriptRefs: requiredWitsLeft.scriptRefs + }) + + const scriptHash = PlutusScriptWitness.hash(partialWitness.scriptWitness) + + // Remove the script hash + const filteredScripts = clearedRequiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const updatedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: clearedRequiredWitsLeft.vkeys, + bootstraps: clearedRequiredWitsLeft.bootstraps, + scripts: filteredScripts, + plutusData: clearedRequiredWitsLeft.plutusData, + redeemers: clearedRequiredWitsLeft.redeemers, + scriptRefs: clearedRequiredWitsLeft.scriptRefs + }) + + // Remove datum hash if provided + let finalRequiredWitsLeft = updatedRequiredWitsLeft + if (datum) { + const datumHash = hashPlutusData(datum) + const filteredPlutusData = updatedRequiredWitsLeft.plutusData.filter((h) => !DatumOption.equals(h, datumHash)) + finalRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: updatedRequiredWitsLeft.vkeys, + bootstraps: updatedRequiredWitsLeft.bootstraps, + scripts: updatedRequiredWitsLeft.scripts, + plutusData: filteredPlutusData, + redeemers: updatedRequiredWitsLeft.redeemers, + scriptRefs: updatedRequiredWitsLeft.scriptRefs + }) + } + + if (finalRequiredWitsLeft.len() > 0) { + return yield* Eff.fail( + new InputBuilderError({ + message: `Missing the following witnesses for the input`, + cause: finalRequiredWitsLeft + }) + ) + } + + return { + input: this.input, + utxoInfo: this.utxoInfo, + aggregateWitness: InputAggregateWitnessData.plutusScript(partialWitness, requiredSigners, datum), + requiredWits + } + }.bind(this) + ) + } +} diff --git a/packages/evolution/src/builders/MintBuilder.ts b/packages/evolution/src/builders/MintBuilder.ts new file mode 100644 index 00000000..43787e58 --- /dev/null +++ b/packages/evolution/src/builders/MintBuilder.ts @@ -0,0 +1,93 @@ +import { Data } from "effect" + +import type * as AssetName from "../core/AssetName.js" +import type * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import * as PolicyId from "../core/PolicyId.js" +import * as ScriptHash from "../core/ScriptHash.js" +import type { NativeScriptWitnessInfo, PartialPlutusWitness } from "./WitnessBuilder.js" +import { InputAggregateWitnessData, PlutusScriptWitness, RequiredWitnessSet } from "./WitnessBuilder.js" + +/** + * Error class for MintBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class MintBuilderError extends Data.TaggedError("MintBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Convert ScriptHash to PolicyId + * Since both are based on Hash28, we can convert by extracting the hash + */ +function scriptHashToPolicyId(scriptHash: ScriptHash.ScriptHash): PolicyId.PolicyId { + return new PolicyId.PolicyId({ hash: scriptHash.hash }, { disableValidation: true }) +} + +/** + * Result of building a mint operation + * + * @since 2.0.0 + * @category model + */ +export interface MintBuilderResult { + policyId: PolicyId.PolicyId + assets: Map + aggregateWitness?: InputAggregateWitnessData + requiredWits: RequiredWitnessSet +} + +/** + * Builder for a single mint operation + * + * @since 2.0.0 + * @category builders + */ +export class SingleMintBuilder { + constructor(public readonly assets: Map) {} + + static new(assets: Map): SingleMintBuilder { + return new SingleMintBuilder(assets) + } + + static newSingleAsset(asset: AssetName.AssetName, amount: bigint): SingleMintBuilder { + const assets = new Map() + assets.set(asset, amount) + return new SingleMintBuilder(assets) + } + + nativeScript(nativeScript: NativeScripts.NativeScript, witnessInfo: NativeScriptWitnessInfo): MintBuilderResult { + const requiredWits = RequiredWitnessSet.default() + const scriptHash = ScriptHash.fromScript(nativeScript) + requiredWits.addScriptHash(scriptHash) + + return { + assets: this.assets, + policyId: scriptHashToPolicyId(scriptHash), + aggregateWitness: InputAggregateWitnessData.nativeScript(nativeScript, witnessInfo), + requiredWits + } + } + + plutusScript(partialWitness: PartialPlutusWitness, requiredSigners: Array): MintBuilderResult { + const requiredWits = RequiredWitnessSet.default() + + const scriptHash = PlutusScriptWitness.hash(partialWitness.scriptWitness) + requiredSigners.forEach((signer) => requiredWits.addVkeyKeyHash(signer)) + requiredWits.addScriptHash(scriptHash) + + return { + assets: this.assets, + policyId: scriptHashToPolicyId(scriptHash), + aggregateWitness: InputAggregateWitnessData.plutusScript( + partialWitness, + requiredSigners, + undefined // No datum for minting + ), + requiredWits + } + } +} diff --git a/packages/evolution/src/builders/OutputBuilder.ts b/packages/evolution/src/builders/OutputBuilder.ts new file mode 100644 index 00000000..133d5bd4 --- /dev/null +++ b/packages/evolution/src/builders/OutputBuilder.ts @@ -0,0 +1,322 @@ +import { Data, Effect as Eff, Schema } from "effect" + +import type * as AddressEras from "../core/AddressEras.js" +import * as Coin from "../core/Coin.js" +import * as PlutusData from "../core/Data.js" +import type * as DatumOption from "../core/DatumOption.js" +import type * as MultiAsset from "../core/MultiAsset.js" +import type * as ScriptRef from "../core/ScriptRef.js" +import * as TransactionOutput from "../core/TransactionOutput.js" +import * as Value from "../core/Value.js" +import { hashPlutusData } from "../utils/Hash.js" +import * as MinAda from "./utils/MinAda.js" + +/** + * Error class for OutputBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class OutputBuilderError extends Data.TaggedError("OutputBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Result of building a single transaction output with optional communication datum. + * Communication datum is the full datum that gets included in witness while + * only its hash goes in the output itself. + * + * @since 2.0.0 + * @category model + */ +export class SingleOutputBuilderResult extends Schema.Class("SingleOutputBuilderResult")({ + output: TransactionOutput.TransactionOutput, + communicationDatum: Schema.optional(PlutusData.DataSchema) +}) { + /** + * Create a new SingleOutputBuilderResult with just an output. + * + * @since 2.0.0 + * @category constructors + */ + static new(output: TransactionOutput.TransactionOutput): SingleOutputBuilderResult { + return new SingleOutputBuilderResult({ + output, + communicationDatum: undefined + }) + } +} + +/** + * Builder for creating transaction outputs - first stage for setting address, datum, and script reference. + * This builder follows a two-stage pattern where basic fields are set first, then amount is set in the second stage. + * + * @since 2.0.0 + * @category builders + */ +export class TransactionOutputBuilder { + private address?: AddressEras.AddressEras + private datum?: DatumOption.DatumOption + private communicationDatum?: PlutusData.Data + private scriptRef?: ScriptRef.ScriptRef + + /** + * Create a new TransactionOutputBuilder. + * + * @since 2.0.0 + * @category constructors + */ + static new(): TransactionOutputBuilder { + return new TransactionOutputBuilder() + } + + /** + * Set the address for the transaction output. + * + * @since 2.0.0 + * @category setters + */ + withAddress(address: AddressEras.AddressEras): TransactionOutputBuilder { + this.address = address + return this + } + + /** + * Set a communication datum. This is a datum where the hash goes in the output + * but the full datum is included in the transaction witness. + * + * @since 2.0.0 + * @category setters + */ + withCommunicationData(datum: PlutusData.Data): TransactionOutputBuilder { + this.datum = hashPlutusData(datum) + this.communicationDatum = datum + return this + } + + /** + * Set the datum option directly (hash or inline datum). + * + * @since 2.0.0 + * @category setters + */ + withData(datum: DatumOption.DatumOption): TransactionOutputBuilder { + this.datum = datum + this.communicationDatum = undefined + return this + } + + /** + * Set the reference script for the transaction output. + * + * @since 2.0.0 + * @category setters + */ + withReferenceScript(scriptRef: ScriptRef.ScriptRef): TransactionOutputBuilder { + this.scriptRef = scriptRef + return this + } + + /** + * Move to the next stage of building where amount is set. + * + * @since 2.0.0 + * @category transitions + */ + next(): Eff.Effect { + if (!this.address) { + return Eff.fail( + new OutputBuilderError({ + message: "Address missing - call withAddress() before next()" + }) + ) + } + + return Eff.succeed( + new TransactionOutputAmountBuilder(this.address, this.datum, this.scriptRef, this.communicationDatum) + ) + } +} + +/** + * Builder for creating transaction outputs - second stage for setting the amount/value. + * This stage handles the more complex logic around minimum ADA requirements. + * + * @since 2.0.0 + * @category builders + */ +export class TransactionOutputAmountBuilder { + private amount?: Value.Value + + constructor( + private readonly address: AddressEras.AddressEras, + private readonly datum?: DatumOption.DatumOption, + private readonly scriptRef?: ScriptRef.ScriptRef, + private readonly communicationDatum?: PlutusData.Data + ) {} + + /** + * Set the value directly. Can be Coin or Value with assets. + * + * @since 2.0.0 + * @category setters + */ + withValue(amount: Value.Value): TransactionOutputAmountBuilder { + this.amount = amount + return this + } + + /** + * Set value from coin amount. + * + * @since 2.0.0 + * @category setters + */ + withCoin(coin: Coin.Coin): TransactionOutputAmountBuilder { + this.amount = Value.onlyCoin(coin) + return this + } + + /** + * Set the assets and calculate minimum required ADA automatically. + * This ensures the output meets the minimum ADA requirement based on the UTXO size. + * Based on CML Rust implementation algorithm. + * + * @since 2.0.0 + * @category setters + */ + withAssetAndMinRequiredCoin( + multiasset: MultiAsset.MultiAsset, + coinsPerUtxoByte: Coin.Coin + ): Eff.Effect { + return Eff.gen( + function* (this: TransactionOutputAmountBuilder) { + // Create a temporary output with zero ADA to get minimum possible size + const tempOutput = TransactionOutput.makeBabbage({ + address: this.address as any, // TODO: Fix address type validation + amount: Value.withAssets(Coin.make(0n), multiasset), + datumOption: this.datum, + scriptRef: this.scriptRef + }) + + // Calculate minimum possible coin requirement + const minPossibleCoin = yield* Eff.mapError( + MinAda.minAdaRequired(tempOutput, coinsPerUtxoByte), + (cause) => + new OutputBuilderError({ + message: "Failed to calculate minimum ADA requirement", + cause + }) + ) + + // Create test output with calculated minimum to double-check + const checkOutput = TransactionOutput.makeBabbage({ + address: this.address as any, + amount: Value.withAssets(minPossibleCoin, multiasset), + datumOption: this.datum, + scriptRef: this.scriptRef + }) + + // Recalculate to ensure accuracy (matches Rust implementation) + const requiredCoin = yield* Eff.mapError( + MinAda.minAdaRequired(checkOutput, coinsPerUtxoByte), + (cause) => + new OutputBuilderError({ + message: "Failed to recalculate minimum ADA requirement", + cause + }) + ) + + // Set the final value with the correctly calculated minimum ADA + this.amount = Value.withAssets(requiredCoin, multiasset) + return this + }.bind(this) + ) + } + + /** + * Build the final transaction output result. + * + * @since 2.0.0 + * @category builders + */ + build(): Eff.Effect { + if (!this.amount) { + return Eff.fail( + new OutputBuilderError({ + message: "Amount missing - call withValue(), withCoin(), or withAssetAndMinRequiredCoin() before build()" + }) + ) + } + + // Use BabbageTransactionOutput for full feature support + // Note: In real implementation, should validate address type first + const output = TransactionOutput.makeBabbage({ + address: this.address as any, // TODO: Add proper address type validation + amount: this.amount, + datumOption: this.datum, + scriptRef: this.scriptRef + }) + + return Eff.succeed( + new SingleOutputBuilderResult({ + output, + communicationDatum: this.communicationDatum + }) + ) + } +} + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace OutputBuilderEffect { + /** + * Create a new TransactionOutputBuilder using Effect error handling. + * + * @since 2.0.0 + * @category constructors + */ + export const newOutputBuilder = (): Eff.Effect => + Eff.succeed(TransactionOutputBuilder.new()) + + /** + * Create a SingleOutputBuilderResult from just an output using Effect error handling. + * + * @since 2.0.0 + * @category constructors + */ + export const newSingleResult = ( + output: TransactionOutput.TransactionOutput + ): Eff.Effect => Eff.succeed(SingleOutputBuilderResult.new(output)) +} + +// ============================================================================ +// Root Namespace Functions (Sync API) +// ============================================================================ + +/** + * Create a new TransactionOutputBuilder. + * + * @since 2.0.0 + * @category constructors + */ +export const newOutputBuilder = (): TransactionOutputBuilder => TransactionOutputBuilder.new() + +/** + * Create a SingleOutputBuilderResult from just an output. + * + * @since 2.0.0 + * @category constructors + */ +export const newSingleResult = (output: TransactionOutput.TransactionOutput): SingleOutputBuilderResult => + SingleOutputBuilderResult.new(output) diff --git a/packages/evolution/src/builders/ProposalBuilder.ts b/packages/evolution/src/builders/ProposalBuilder.ts new file mode 100644 index 00000000..22025ce7 --- /dev/null +++ b/packages/evolution/src/builders/ProposalBuilder.ts @@ -0,0 +1,239 @@ +import { Data, Effect as Eff } from "effect" + +import type * as PlutusData from "../core/Data.js" +import * as DatumOption from "../core/DatumOption.js" +import type * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import type * as ProposalProcedure from "../core/ProposalProcedure.js" +import * as ScriptHash from "../core/ScriptHash.js" +import { hashPlutusData } from "../utils/Hash.js" +import type { NativeScriptWitnessInfo, PartialPlutusWitness } from "./WitnessBuilder.js" +import { InputAggregateWitnessData, PlutusScriptWitness, RequiredWitnessSet } from "./WitnessBuilder.js" + +/** + * Error class for ProposalBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class ProposalBuilderError extends Data.TaggedError("ProposalBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Result of building proposals + * + * @since 2.0.0 + * @category model + */ +export interface ProposalBuilderResult { + proposals: Array + requiredWits: RequiredWitnessSet + aggregateWitnesses: Array +} + +/** + * Builder for governance proposals + * + * @since 2.0.0 + * @category builders + */ +export class ProposalBuilder { + private result: ProposalBuilderResult + + constructor() { + this.result = { + proposals: [], + requiredWits: RequiredWitnessSet.default(), + aggregateWitnesses: [] + } + } + + static new(): ProposalBuilder { + return new ProposalBuilder() + } + + withProposal(proposal: ProposalProcedure.ProposalProcedure): Eff.Effect { + return Eff.gen( + function* (this: ProposalBuilder) { + // Check if proposal uses script hash + const scriptHash = getProposalScriptHash(proposal) + if (scriptHash) { + return yield* Eff.fail( + new ProposalBuilderError({ + message: "Proposal uses script. Call withPlutusProposal() instead." + }) + ) + } + + this.result.proposals.push(proposal) + return this + }.bind(this) + ) + } + + withNativeScriptProposal( + proposal: ProposalProcedure.ProposalProcedure, + nativeScript: NativeScripts.NativeScript, + witnessInfo: NativeScriptWitnessInfo + ): Eff.Effect { + return Eff.gen( + function* (this: ProposalBuilder) { + const proposalScriptHash = getProposalScriptHash(proposal) + const scriptHash = ScriptHash.fromScript(nativeScript) + + if (!proposalScriptHash) { + return yield* Eff.fail( + new ProposalBuilderError({ + message: "Proposal uses key hash. Call withProposal() instead." + }) + ) + } + + if (!ScriptHash.equals(proposalScriptHash, scriptHash)) { + const errRequiredWits = RequiredWitnessSet.default() + errRequiredWits.addScriptHash(proposalScriptHash) + return yield* Eff.fail( + new ProposalBuilderError({ + message: "Missing the following witnesses for the proposal", + cause: errRequiredWits + }) + ) + } + + this.result.requiredWits.addScriptHash(proposalScriptHash) + this.result.proposals.push(proposal) + this.result.aggregateWitnesses.push(InputAggregateWitnessData.nativeScript(nativeScript, witnessInfo)) + + return this + }.bind(this) + ) + } + + withPlutusProposal( + proposal: ProposalProcedure.ProposalProcedure, + partialWitness: PartialPlutusWitness, + requiredSigners: Array, + datum: PlutusData.Data + ): Eff.Effect { + return this.withPlutusProposalImpl(proposal, partialWitness, requiredSigners, datum) + } + + withPlutusProposalInlineDatum( + proposal: ProposalProcedure.ProposalProcedure, + partialWitness: PartialPlutusWitness, + requiredSigners: Array + ): Eff.Effect { + return this.withPlutusProposalImpl(proposal, partialWitness, requiredSigners, undefined) + } + + private withPlutusProposalImpl( + proposal: ProposalProcedure.ProposalProcedure, + partialWitness: PartialPlutusWitness, + requiredSigners: Array, + datum?: PlutusData.Data + ): Eff.Effect { + return Eff.gen( + function* (this: ProposalBuilder) { + const requiredWits = RequiredWitnessSet.default() + requiredSigners.forEach((signer) => requiredWits.addVkeyKeyHash(signer)) + + const proposalScriptHash = getProposalScriptHash(proposal) + if (!proposalScriptHash) { + return yield* Eff.fail( + new ProposalBuilderError({ + message: "Proposal uses key hash. Call withProposal() instead." + }) + ) + } + + requiredWits.addScriptHash(proposalScriptHash) + const requiredWitsLeft = structuredClone(requiredWits) + + // Clear vkeys as we don't know which ones will be used + const clearedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: [], // Cleared + bootstraps: requiredWitsLeft.bootstraps, + scripts: requiredWitsLeft.scripts, + plutusData: requiredWitsLeft.plutusData, + redeemers: requiredWitsLeft.redeemers, + scriptRefs: requiredWitsLeft.scriptRefs + }) + + const scriptHash = PlutusScriptWitness.hash(partialWitness.scriptWitness) + + // Remove the script hash + const filteredScripts = clearedRequiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const updatedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: clearedRequiredWitsLeft.vkeys, + bootstraps: clearedRequiredWitsLeft.bootstraps, + scripts: filteredScripts, + plutusData: clearedRequiredWitsLeft.plutusData, + redeemers: clearedRequiredWitsLeft.redeemers, + scriptRefs: clearedRequiredWitsLeft.scriptRefs + }) + + // Remove datum hash if provided + let finalRequiredWitsLeft = updatedRequiredWitsLeft + if (datum) { + const datumHash = hashPlutusData(datum) + const filteredPlutusData = updatedRequiredWitsLeft.plutusData.filter((h) => !DatumOption.equals(h, datumHash)) + finalRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: updatedRequiredWitsLeft.vkeys, + bootstraps: updatedRequiredWitsLeft.bootstraps, + scripts: updatedRequiredWitsLeft.scripts, + plutusData: filteredPlutusData, + redeemers: updatedRequiredWitsLeft.redeemers, + scriptRefs: updatedRequiredWitsLeft.scriptRefs + }) + } + + if (finalRequiredWitsLeft.len() > 0) { + return yield* Eff.fail( + new ProposalBuilderError({ + message: "Missing the following witnesses for the proposal", + cause: finalRequiredWitsLeft + }) + ) + } + + this.result.proposals.push(proposal) + this.result.requiredWits.addAll(requiredWits) + this.result.aggregateWitnesses.push( + InputAggregateWitnessData.plutusScript(partialWitness, requiredSigners, datum) + ) + + return this + }.bind(this) + ) + } + + build(): ProposalBuilderResult { + return this.result + } +} + +/** + * Helper function to get script hash from a proposal + * Returns undefined if proposal uses key hash + * Based on Conway CDDL: only ParameterChangeAction and TreasuryWithdrawalsAction have policy_hash + */ +function getProposalScriptHash(proposal: ProposalProcedure.ProposalProcedure): ScriptHash.ScriptHash | undefined { + const action = proposal.governanceAction + + switch (action._tag) { + case "ParameterChangeAction": + return action.policyHash || undefined + case "TreasuryWithdrawalsAction": + return action.policyHash || undefined + case "HardForkInitiationAction": + case "NoConfidenceAction": + case "UpdateCommitteeAction": + case "NewConstitutionAction": + case "InfoAction": + return undefined + default: + return undefined + } +} diff --git a/packages/evolution/src/builders/RedeemerBuilder.ts b/packages/evolution/src/builders/RedeemerBuilder.ts new file mode 100644 index 00000000..b2103f14 --- /dev/null +++ b/packages/evolution/src/builders/RedeemerBuilder.ts @@ -0,0 +1,432 @@ +import { Data, Effect as Eff, Schema } from "effect" + +import * as PlutusData from "../core/Data.js" +import type * as PolicyId from "../core/PolicyId.js" +import * as Redeemer from "../core/Redeemer.js" +import type * as RewardAddress from "../core/RewardAddress.js" +import type * as TransactionInput from "../core/TransactionInput.js" +import type { RedeemerWitnessKey } from "./WitnessBuilder.js" + +/** + * Error class for missing execution units. + * + * @since 2.0.0 + * @category errors + */ +export class MissingExunitError extends Data.TaggedError("MissingExunitError")<{ + message?: string + tag: Redeemer.RedeemerTag + index: number + key: string +}> {} + +/** + * Error class for RedeemerBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class RedeemerBuilderError extends Data.TaggedError("RedeemerBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Redeemer without the tag or index for builder code to return partial redeemers. + * + * @since 2.0.0 + * @category model + */ +export class UntaggedRedeemer extends Schema.Class("UntaggedRedeemer")({ + data: PlutusData.DataSchema, + exUnits: Redeemer.ExUnits +}) { + static new(data: PlutusData.Data, exUnits: Redeemer.ExUnits): UntaggedRedeemer { + return new UntaggedRedeemer({ data, exUnits }) + } +} + +/** + * Union type for untagged redeemer placeholders. + * + * @since 2.0.0 + * @category model + */ +export const UntaggedRedeemerPlaceholder = Schema.Union( + Schema.Struct({ + _tag: Schema.Literal("JustData"), + data: PlutusData.DataSchema + }), + Schema.Struct({ + _tag: Schema.Literal("Full"), + redeemer: UntaggedRedeemer + }) +).annotations({ + identifier: "UntaggedRedeemerPlaceholder", + description: "Placeholder for redeemer data that may be partial or complete" +}) + +export type UntaggedRedeemerPlaceholder = typeof UntaggedRedeemerPlaceholder.Type + +/** + * Helper function to extract data from an untagged redeemer placeholder. + * + * @since 2.0.0 + * @category utilities + */ +export const getPlaceholderData = (placeholder: UntaggedRedeemerPlaceholder): PlutusData.Data => { + switch (placeholder._tag) { + case "JustData": + return placeholder.data + case "Full": + return placeholder.redeemer.data + } +} + +/** + * Builder for creating redeemer sets. + * + * In order to calculate the index from the sorted set, "add*" methods in this builder + * must be called along with the "add*" methods in transaction builder. + * + * @since 2.0.0 + * @category builders + */ +export class RedeemerSetBuilder { + private spend: Map = new Map() + private mint: Map = new Map() + private reward: Map = new Map() + private cert: Array = [] + private proposals: Array = [] + private votes: Array = [] + + /** + * Create a new RedeemerSetBuilder instance. + * + * @since 2.0.0 + * @category constructors + */ + static new(): RedeemerSetBuilder { + return new RedeemerSetBuilder() + } + + /** + * Check if the builder is empty (no redeemers tracked). + * + * @since 2.0.0 + * @category utilities + */ + isEmpty(): boolean { + return ( + this.spend.size === 0 && + this.mint.size === 0 && + this.reward.size === 0 && + this.cert.length === 0 && + this.proposals.length === 0 && + this.votes.length === 0 + ) + } + + /** + * Update execution units for a specific redeemer. + * Will override existing value if called twice with the same key. + * + * @since 2.0.0 + * @category updates + */ + updateExUnits(key: RedeemerWitnessKey, exUnits: Redeemer.ExUnits): Eff.Effect { + const index = Number(key.index) + + switch (key.tag) { + case "spend": { + const entries = Array.from(this.spend.entries()).sort((a, b) => a[0].localeCompare(b[0])) + if (index >= entries.length) { + return Eff.fail( + new RedeemerBuilderError({ + message: `Spend index ${index} out of bounds`, + cause: new Error(`Only ${entries.length} spend entries available`) + }) + ) + } + const [inputKey, placeholder] = entries[index] + if (!placeholder) { + return Eff.fail( + new RedeemerBuilderError({ + message: "Cannot update ex units for null placeholder" + }) + ) + } + const data = getPlaceholderData(placeholder) + this.spend.set(inputKey, { + _tag: "Full", + redeemer: UntaggedRedeemer.new(data, exUnits) + }) + return Eff.succeed(undefined) + } + case "mint": { + const entries = Array.from(this.mint.entries()).sort((a, b) => a[0].localeCompare(b[0])) + if (index >= entries.length) { + return Eff.fail( + new RedeemerBuilderError({ + message: `Mint index ${index} out of bounds`, + cause: new Error(`Only ${entries.length} mint entries available`) + }) + ) + } + const [policyKey, placeholder] = entries[index] + if (!placeholder) { + return Eff.fail( + new RedeemerBuilderError({ + message: "Cannot update ex units for null placeholder" + }) + ) + } + const data = getPlaceholderData(placeholder) + this.mint.set(policyKey, { + _tag: "Full", + redeemer: UntaggedRedeemer.new(data, exUnits) + }) + return Eff.succeed(undefined) + } + case "reward": { + const entries = Array.from(this.reward.entries()).sort((a, b) => a[0].localeCompare(b[0])) + if (index >= entries.length) { + return Eff.fail( + new RedeemerBuilderError({ + message: `Reward index ${index} out of bounds`, + cause: new Error(`Only ${entries.length} reward entries available`) + }) + ) + } + const [rewardKey, placeholder] = entries[index] + if (!placeholder) { + return Eff.fail( + new RedeemerBuilderError({ + message: "Cannot update ex units for null placeholder" + }) + ) + } + const data = getPlaceholderData(placeholder) + this.reward.set(rewardKey, { + _tag: "Full", + redeemer: UntaggedRedeemer.new(data, exUnits) + }) + return Eff.succeed(undefined) + } + case "cert": { + if (index >= this.cert.length) { + return Eff.fail( + new RedeemerBuilderError({ + message: `Cert index ${index} out of bounds`, + cause: new Error(`Only ${this.cert.length} cert entries available`) + }) + ) + } + const placeholder = this.cert[index] + if (!placeholder) { + return Eff.fail( + new RedeemerBuilderError({ + message: "Cannot update ex units for null placeholder" + }) + ) + } + const data = getPlaceholderData(placeholder) + this.cert[index] = { + _tag: "Full", + redeemer: UntaggedRedeemer.new(data, exUnits) + } + return Eff.succeed(undefined) + } + } + } + + /** + * Add a spend input result to the builder. + * + * @since 2.0.0 + * @category adds + */ + addSpend(input: TransactionInput.TransactionInput, redeemerData?: PlutusData.Data): void { + const key = JSON.stringify(input) + if (redeemerData) { + this.spend.set(key, { _tag: "JustData", data: redeemerData }) + } else { + this.spend.set(key, null) + } + } + + /** + * Add a mint result to the builder. + * + * @since 2.0.0 + * @category adds + */ + addMint(policyId: PolicyId.PolicyId, redeemerData?: PlutusData.Data): void { + const key = JSON.stringify(policyId) + if (redeemerData) { + this.mint.set(key, { _tag: "JustData", data: redeemerData }) + } else { + this.mint.set(key, null) + } + } + + /** + * Add a reward withdrawal result to the builder. + * + * @since 2.0.0 + * @category adds + */ + addReward(address: RewardAddress.RewardAddress, redeemerData?: PlutusData.Data): void { + const key = JSON.stringify(address) + if (redeemerData) { + this.reward.set(key, { _tag: "JustData", data: redeemerData }) + } else { + this.reward.set(key, null) + } + } + + /** + * Add a certificate result to the builder. + * + * @since 2.0.0 + * @category adds + */ + addCert(redeemerData?: PlutusData.Data): void { + if (redeemerData) { + this.cert.push({ _tag: "JustData", data: redeemerData }) + } else { + this.cert.push(null) + } + } + + /** + * Add proposal results to the builder. + * + * @since 2.0.0 + * @category adds + */ + addProposal(redeemerData?: PlutusData.Data): void { + if (redeemerData) { + this.proposals.push({ _tag: "JustData", data: redeemerData }) + } else { + this.proposals.push(null) + } + } + + /** + * Add vote results to the builder. + * + * @since 2.0.0 + * @category adds + */ + addVote(redeemerData?: PlutusData.Data): void { + if (redeemerData) { + this.votes.push({ _tag: "JustData", data: redeemerData }) + } else { + this.votes.push(null) + } + } + + /** + * Build the final redeemers array. + * + * @since 2.0.0 + * @category builders + */ + build(defaultToDummyExunits: boolean = false): Eff.Effect, RedeemerBuilderError> { + const redeemers: Array = [] + + const spendEntries = Array.from(this.spend.entries()).sort((a, b) => a[0].localeCompare(b[0])) + const mintEntries = Array.from(this.mint.entries()).sort((a, b) => a[0].localeCompare(b[0])) + const rewardEntries = Array.from(this.reward.entries()).sort((a, b) => a[0].localeCompare(b[0])) + const certEntries = this.cert.map( + (entry: UntaggedRedeemerPlaceholder | null, i: number) => + [`${i}`, entry] as [string, UntaggedRedeemerPlaceholder | null] + ) + + return Eff.Do.pipe( + Eff.tap(() => this.removePlaceholdersAndTag(redeemers, "spend", spendEntries, defaultToDummyExunits)), + Eff.tap(() => this.removePlaceholdersAndTag(redeemers, "mint", mintEntries, defaultToDummyExunits)), + Eff.tap(() => this.removePlaceholdersAndTag(redeemers, "reward", rewardEntries, defaultToDummyExunits)), + Eff.tap(() => this.removePlaceholdersAndTag(redeemers, "cert", certEntries, defaultToDummyExunits)), + Eff.map(() => redeemers) + ) + } + + private removePlaceholdersAndTag( + redeemers: Array, + tag: Redeemer.RedeemerTag, + entries: Array<[string, UntaggedRedeemerPlaceholder | null]>, + defaultToDummyExunits: boolean + ): Eff.Effect { + try { + const results: Array = [] + + for (let i = 0; i < entries.length; i++) { + const [key, placeholder] = entries[i] + + if (!placeholder) { + results.push(null) + continue + } + + switch (placeholder._tag) { + case "JustData": + if (!defaultToDummyExunits) { + return Eff.fail( + new RedeemerBuilderError({ + message: "Missing execution units", + cause: new MissingExunitError({ + message: `Missing exunit for ${tag} with key ${key} and index ${i}`, + tag, + index: i, + key + }) + }) + ) + } else { + results.push(UntaggedRedeemer.new(placeholder.data, [BigInt(0), BigInt(0)])) + } + break + case "Full": + results.push(placeholder.redeemer) + break + } + } + + const taggedRedeemers = this.tagRedeemers(tag, results) + redeemers.push(...taggedRedeemers) + return Eff.succeed(undefined) + } catch (error) { + return Eff.fail( + new RedeemerBuilderError({ + message: `Failed to process ${tag} redeemers`, + cause: error + }) + ) + } + } + + private tagRedeemers( + tag: Redeemer.RedeemerTag, + untaggedRedeemers: Array + ): Array { + const results: Array = [] + + for (let index = 0; index < untaggedRedeemers.length; index++) { + const untagged = untaggedRedeemers[index] + if (untagged) { + results.push( + new Redeemer.Redeemer({ + tag, + index: BigInt(index), + data: untagged.data, + exUnits: untagged.exUnits + }) + ) + } + } + + return results + } +} diff --git a/packages/evolution/src/builders/TxBuilder.ts b/packages/evolution/src/builders/TxBuilder.ts new file mode 100644 index 00000000..eff91e18 --- /dev/null +++ b/packages/evolution/src/builders/TxBuilder.ts @@ -0,0 +1,986 @@ +import { Data, Effect as Eff, Schema } from "effect" +import type { NonEmptyArray } from "effect/Array" + +import type * as AddressEras from "../core/AddressEras.js" +import type * as AuxiliaryData from "../core/AuxiliaryData.js" +import * as Coin from "../core/Coin.js" +import * as KeyHash from "../core/KeyHash.js" +import * as Mint from "../core/Mint.js" +import type * as NetworkId from "../core/NetworkId.js" +import * as NonZeroInt64 from "../core/NonZeroInt64.js" +import * as Transaction from "../core/Transaction.js" +import * as TransactionBody from "../core/TransactionBody.js" +import * as TransactionInput from "../core/TransactionInput.js" +import * as TransactionOutput from "../core/TransactionOutput.js" +import * as TransactionWitnessSet from "../core/TransactionWitnessSet.js" +import * as Value from "../core/Value.js" +import * as Withdrawals from "../core/Withdrawals.js" +import * as Hash from "../utils/Hash.js" +import type { CertificateBuilderResult } from "./CertificateBuilder.js" +import type { InputBuilderResult } from "./InputBuilder.js" +import type { MintBuilderResult } from "./MintBuilder.js" +import { type SingleOutputBuilderResult } from "./OutputBuilder.js" +import type { ProposalBuilderResult } from "./ProposalBuilder.js" +import type { VoteBuilderResult } from "./VoteBuilder.js" +import type { WithdrawalBuilderResult } from "./WithdrawalBuilder.js" + +/** + * Error class for TxBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class TxBuilderError extends Data.TaggedError("TxBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Configuration error for missing transaction builder parameters. + * + * @since 2.0.0 + * @category errors + */ +export class TxBuilderConfigError extends Data.TaggedError("TxBuilderConfigError")<{ + message?: string + missingFields?: Array +}> {} + +/** + * UTXO structure for transaction inputs. + * This matches the CIP30 interface and is useful for builders. + * + * @since 2.0.0 + * @category model + */ +export class TransactionUnspentOutput extends Schema.Class("TransactionUnspentOutput")({ + input: TransactionInput.TransactionInput, + output: TransactionOutput.TransactionOutput +}) { + /** + * Create a new TransactionUnspentOutput. + * + * @since 2.0.0 + * @category constructors + */ + static new( + input: TransactionInput.TransactionInput, + output: TransactionOutput.TransactionOutput + ): TransactionUnspentOutput { + return new TransactionUnspentOutput({ input, output }) + } +} + +/** + * Coin selection strategy based on CIP-2 standard. + * + * @since 2.0.0 + * @category model + */ +export const CoinSelectionStrategyCIP2 = Schema.Literal( + "LargestFirst", + "RandomImprove", + "RandomImproveMultiAsset" +).annotations({ + identifier: "TxBuilder.CoinSelectionStrategyCIP2", + description: "Coin selection algorithms implementing CIP-2" +}) + +export type CoinSelectionStrategyCIP2 = typeof CoinSelectionStrategyCIP2.Type + +/** + * Change selection algorithm for creating change outputs. + * + * @since 2.0.0 + * @category model + */ +export const ChangeSelectionAlgo = Schema.Literal("Default").annotations({ + identifier: "TxBuilder.ChangeSelectionAlgo", + description: "Algorithm for creating transaction change outputs" +}) + +export type ChangeSelectionAlgo = typeof ChangeSelectionAlgo.Type + +/** + * Linear fee algorithm configuration. + * + * @since 2.0.0 + * @category model + */ +export class LinearFee extends Schema.Class("LinearFee")({ + constant: Coin.Coin.annotations({ + description: "Base fee constant in lovelace" + }), + coefficient: Coin.Coin.annotations({ + description: "Fee coefficient per byte in lovelace" + }) +}) {} + +/** + * Ex-unit prices for script execution. + * + * @since 2.0.0 + * @category model + */ +export class ExUnitPrices extends Schema.Class("ExUnitPrices")({ + memPrice: Schema.Struct({ + numerator: Schema.BigInt, + denominator: Schema.BigInt + }), + stepPrice: Schema.Struct({ + numerator: Schema.BigInt, + denominator: Schema.BigInt + }) +}) {} + +/** + * Transaction builder configuration with protocol parameters. + * + * @since 2.0.0 + * @category model + */ +export class TransactionBuilderConfig extends Schema.Class("TransactionBuilderConfig")({ + feeAlgo: LinearFee, + coinsPerUtxoByte: Coin.Coin, + poolDeposit: Coin.Coin, + keyDeposit: Coin.Coin, + maxValueSize: Schema.Number, + maxTxSize: Schema.Number, + utxoCostPerWord: Schema.optional(Coin.Coin), + exUnitPrices: Schema.optional(ExUnitPrices), + preferPureChange: Schema.optional(Schema.Boolean) +}) {} + +/** + * Builder for creating TransactionBuilderConfig with validation. + * + * @since 2.0.0 + * @category builders + */ +export class TransactionBuilderConfigBuilder { + private feeAlgo?: LinearFee + private coinsPerUtxoByte?: Coin.Coin + private poolDeposit?: Coin.Coin + private keyDeposit?: Coin.Coin + private maxValueSize?: number + private maxTxSize?: number + private utxoCostPerWord?: Coin.Coin + private exUnitPrices?: ExUnitPrices + private preferPureChange?: boolean + + /** + * Create a new TransactionBuilderConfigBuilder. + * + * @since 2.0.0 + * @category constructors + */ + static new(): TransactionBuilderConfigBuilder { + return new TransactionBuilderConfigBuilder() + } + + /** + * Set the fee algorithm. + * + * @since 2.0.0 + * @category setters + */ + feeAlgorithm(feeAlgo: LinearFee): TransactionBuilderConfigBuilder { + this.feeAlgo = feeAlgo + return this + } + + /** + * Set coins per UTXO byte for minimum ADA calculation. + * + * @since 2.0.0 + * @category setters + */ + coinsPerUtxoWord(coins: Coin.Coin): TransactionBuilderConfigBuilder { + this.coinsPerUtxoByte = coins + return this + } + + /** + * Set pool registration deposit. + * + * @since 2.0.0 + * @category setters + */ + poolDepositAmount(deposit: Coin.Coin): TransactionBuilderConfigBuilder { + this.poolDeposit = deposit + return this + } + + /** + * Set key registration deposit. + * + * @since 2.0.0 + * @category setters + */ + keyDepositAmount(deposit: Coin.Coin): TransactionBuilderConfigBuilder { + this.keyDeposit = deposit + return this + } + + /** + * Set maximum value size per output. + * + * @since 2.0.0 + * @category setters + */ + maxValueSizeLimit(size: number): TransactionBuilderConfigBuilder { + this.maxValueSize = size + return this + } + + /** + * Set maximum transaction size. + * + * @since 2.0.0 + * @category setters + */ + maxTxSizeLimit(size: number): TransactionBuilderConfigBuilder { + this.maxTxSize = size + return this + } + + /** + * Set UTXO cost per word (legacy parameter). + * + * @since 2.0.0 + * @category setters + */ + utxoCostPerWordAmount(cost: Coin.Coin): TransactionBuilderConfigBuilder { + this.utxoCostPerWord = cost + return this + } + + /** + * Set execution unit prices for script fees. + * + * @since 2.0.0 + * @category setters + */ + executionUnitPrices(prices: ExUnitPrices): TransactionBuilderConfigBuilder { + this.exUnitPrices = prices + return this + } + + /** + * Set preference for pure change (no assets). + * + * @since 2.0.0 + * @category setters + */ + preferPureChangeOutput(prefer: boolean): TransactionBuilderConfigBuilder { + this.preferPureChange = prefer + return this + } + + /** + * Build the configuration with validation. + * + * @since 2.0.0 + * @category builders + */ + build(): Eff.Effect { + const missingFields: Array = [] + + if (!this.feeAlgo) missingFields.push("feeAlgo") + if (!this.coinsPerUtxoByte) missingFields.push("coinsPerUtxoByte") + if (!this.poolDeposit) missingFields.push("poolDeposit") + if (!this.keyDeposit) missingFields.push("keyDeposit") + if (this.maxValueSize === undefined) missingFields.push("maxValueSize") + if (this.maxTxSize === undefined) missingFields.push("maxTxSize") + + if (missingFields.length > 0) { + return Eff.fail( + new TxBuilderConfigError({ + message: `Missing required configuration fields: ${missingFields.join(", ")}`, + missingFields + }) + ) + } + + return Eff.succeed( + new TransactionBuilderConfig({ + feeAlgo: this.feeAlgo!, + coinsPerUtxoByte: this.coinsPerUtxoByte!, + poolDeposit: this.poolDeposit!, + keyDeposit: this.keyDeposit!, + maxValueSize: this.maxValueSize!, + maxTxSize: this.maxTxSize!, + utxoCostPerWord: this.utxoCostPerWord, + exUnitPrices: this.exUnitPrices, + preferPureChange: this.preferPureChange + }) + ) + } +} + +/** + * Result of building a signed transaction with body and witness set. + * + * @since 2.0.0 + * @category model + */ +export class SignedTxBuilder extends Schema.Class("SignedTxBuilder")({ + body: TransactionBody.TransactionBody, + witnessSet: TransactionWitnessSet.TransactionWitnessSet, + auxiliaryData: Schema.optional(Schema.Any) // AuxiliaryData when available +}) { + /** + * Build the final transaction. + * + * @since 2.0.0 + * @category builders + */ + build(): Transaction.Transaction { + return new Transaction.Transaction({ + body: this.body, + witnessSet: this.witnessSet, + isValid: true, + auxiliaryData: this.auxiliaryData + }) + } +} + +/** + * Main transaction builder for constructing Cardano transactions. + * Handles inputs, outputs, certificates, withdrawals, minting, fees, and witness requirements. + * + * @since 2.0.0 + * @category builders + */ +export class TransactionBuilder { + private inputs: Array = [] + private outputs: Array = [] + private utxos: Array = [] + private referenceInputs: Array = [] + private certificates: Array = [] + private withdrawals: Array = [] + private mints: Array = [] + private proposals: Array = [] + private votes: Array = [] + private collateral: Array = [] + private requiredSigners: Set = new Set() // Use hex representation for deduplication + private fee?: Coin.Coin + private ttl?: bigint + private validityStart?: bigint + private auxiliaryData?: AuxiliaryData.AuxiliaryData + private networkId?: NetworkId.NetworkId + + constructor(private readonly config: TransactionBuilderConfig) {} + + /** + * Create a new TransactionBuilder with configuration. + * + * @since 2.0.0 + * @category constructors + */ + static new(config: TransactionBuilderConfig): TransactionBuilder { + return new TransactionBuilder(config) + } + + // ============================================================================ + // Input/Output Management + // ============================================================================ + + /** + * Add a transaction input with witness requirements. + * + * @since 2.0.0 + * @category inputs + */ + addInput(result: InputBuilderResult): Eff.Effect { + this.inputs.push(result) + return Eff.succeed(undefined) + } + + /** + * Add a UTXO for coin selection. + * + * @since 2.0.0 + * @category inputs + */ + addUtxo(result: InputBuilderResult): void { + this.utxos.push(result) + } + + /** + * Add a reference input (read-only). + * + * @since 2.0.0 + * @category inputs + */ + addReferenceInput(utxo: TransactionUnspentOutput): void { + this.referenceInputs.push(utxo) + } + + /** + * Add a transaction output. + * + * @since 2.0.0 + * @category outputs + */ + addOutput(result: SingleOutputBuilderResult): Eff.Effect { + // Validate output size doesn't exceed max value size + if (this.getOutputSize(result) > this.config.maxValueSize) { + return Eff.fail( + new TxBuilderError({ + message: `Output exceeds max value size of ${this.config.maxValueSize} bytes` + }) + ) + } + this.outputs.push(result) + return Eff.succeed(undefined) + } + + // ============================================================================ + // Transaction Components + // ============================================================================ + + /** + * Add a certificate. + * + * @since 2.0.0 + * @category components + */ + addCert(result: CertificateBuilderResult): void { + this.certificates.push(result) + } + + /** + * Add a withdrawal. + * + * @since 2.0.0 + * @category components + */ + addWithdrawal(result: WithdrawalBuilderResult): void { + this.withdrawals.push(result) + } + + /** + * Add a mint operation. + * + * @since 2.0.0 + * @category components + */ + addMint(result: MintBuilderResult): Eff.Effect { + this.mints.push(result) + return Eff.succeed(undefined) + } + + /** + * Add a governance proposal. + * + * @since 2.0.0 + * @category governance + */ + addProposal(result: ProposalBuilderResult): void { + this.proposals.push(result) + } + + /** + * Add a governance vote. + * + * @since 2.0.0 + * @category governance + */ + addVote(result: VoteBuilderResult): void { + this.votes.push(result) + } + + /** + * Add a collateral input. + * + * @since 2.0.0 + * @category collateral + */ + addCollateral(result: InputBuilderResult): Eff.Effect { + this.collateral.push(result) + return Eff.succeed(undefined) + } + + /** + * Add auxiliary data (metadata). + * + * @since 2.0.0 + * @category metadata + */ + addAuxiliaryData(auxData: AuxiliaryData.AuxiliaryData): void { + this.auxiliaryData = auxData + } + + /** + * Add a required signer. + * + * @since 2.0.0 + * @category signers + */ + addRequiredSigner(keyHash: KeyHash.KeyHash): void { + this.requiredSigners.add(KeyHash.toHex(keyHash)) + } + + // ============================================================================ + // Fee and Time Management + // ============================================================================ + + /** + * Set the transaction fee explicitly. + * + * @since 2.0.0 + * @category fees + */ + setFee(fee: Coin.Coin): void { + this.fee = fee + } + + /** + * Set the time-to-live (TTL) for the transaction. + * + * @since 2.0.0 + * @category time + */ + setTtl(ttl: bigint): void { + this.ttl = ttl + } + + /** + * Set the validity start interval. + * + * @since 2.0.0 + * @category time + */ + setValidityStartInterval(start: bigint): void { + this.validityStart = start + } + + /** + * Set the network ID. + * + * @since 2.0.0 + * @category network + */ + setNetworkId(networkId: NetworkId.NetworkId): void { + this.networkId = networkId + } + + // ============================================================================ + // Coin Selection + // ============================================================================ + + /** + * Select UTXOs using the specified coin selection strategy. + * + * @since 2.0.0 + * @category selection + */ + selectUtxos(strategy: CoinSelectionStrategyCIP2): Eff.Effect { + return Eff.gen( + function* (this: TransactionBuilder) { + const outputValue = this.calculateOutputValue() + const requiredValue = Value.add(outputValue, Value.onlyCoin(this.fee || Coin.make(BigInt(0)))) + + switch (strategy) { + case "LargestFirst": + yield* this.selectLargestFirst(requiredValue) + break + case "RandomImprove": + yield* this.selectRandomImprove(requiredValue) + break + case "RandomImproveMultiAsset": + yield* this.selectRandomImproveMultiAsset(requiredValue) + break + } + }.bind(this) + ) + } + + // ============================================================================ + // Building + // ============================================================================ + + /** + * Build the final signed transaction. + * + * @since 2.0.0 + * @category builders + */ + build( + changeAlgo: ChangeSelectionAlgo, + changeAddress: AddressEras.AddressEras + ): Eff.Effect { + return Eff.gen( + function* (this: TransactionBuilder) { + // Calculate and validate balance + yield* this.validateBalance() + + // Create change outputs if needed + const changeOutputs = yield* this.createChangeOutputs(changeAlgo, changeAddress) + + // Build transaction body + const body = yield* this.buildTransactionBody(changeOutputs) + + // Build witness set + const witnessSet = yield* this.buildWitnessSet(body) + + return new SignedTxBuilder({ + body, + witnessSet, + auxiliaryData: this.auxiliaryData + }) + }.bind(this) + ) + } + + /** + * Calculate minimum fee for the transaction. + * + * @since 2.0.0 + * @category fees + */ + minFee(): Eff.Effect { + return Eff.gen( + function* (this: TransactionBuilder) { + // Estimate transaction size with fake witnesses + const estimatedSize = yield* this.estimateTransactionSize() + + // Calculate linear fee + const baseFee = Coin.add(this.config.feeAlgo.constant, this.config.feeAlgo.coefficient * BigInt(estimatedSize)) + + // Add script execution fees if any + const scriptFee = yield* this.calculateScriptFees() + + return Coin.add(baseFee, scriptFee) + }.bind(this) + ) + } + + // ============================================================================ + // Private Implementation + // ============================================================================ + + private getOutputSize(result: SingleOutputBuilderResult): number { + // Calculate actual CBOR size of the output + try { + const cborBytes = TransactionOutput.toCBORBytes(result.output) + return cborBytes.length + } catch { + // Fall back to conservative estimate if encoding fails + return 200 + } + } + + private calculateOutputValue(): Value.Value { + return this.outputs.reduce( + (total: Value.Value, output) => Value.add(total, output.output.amount), + Value.onlyCoin(Coin.make(0n)) + ) + } + + private selectLargestFirst(requiredValue: Value.Value): Eff.Effect { + // Sort UTXOs by coin amount descending + const sortedUtxos = [...this.utxos].sort((a, b) => { + const coinA = Value.getAda(a.utxoInfo.amount) + const coinB = Value.getAda(b.utxoInfo.amount) + return Coin.compare(coinB, coinA) // Descending order + }) + + let selectedValue: Value.Value = Value.onlyCoin(Coin.make(0n)) + const selectedUtxos: Array = [] + + for (const utxo of sortedUtxos) { + selectedUtxos.push(utxo) + selectedValue = Value.add(selectedValue, utxo.utxoInfo.amount) + + if (Value.geq(selectedValue, requiredValue)) { + break + } + } + + if (!Value.geq(selectedValue, requiredValue)) { + return Eff.fail( + new TxBuilderError({ + message: "Insufficient funds for transaction" + }) + ) + } + + this.inputs.push(...selectedUtxos) + return Eff.succeed(undefined) + } + + private selectRandomImprove(requiredValue: Value.Value): Eff.Effect { + // Simplified random improve - select randomly first, then improve + return this.selectLargestFirst(requiredValue) + } + + private selectRandomImproveMultiAsset(requiredValue: Value.Value): Eff.Effect { + // Simplified multi-asset random improve + return this.selectLargestFirst(requiredValue) + } + + private validateBalance(): Eff.Effect { + const inputValue = this.inputs.reduce( + (total: Value.Value, input) => Value.add(total, input.utxoInfo.amount), + Value.onlyCoin(Coin.make(0n)) + ) + + const outputValue = this.calculateOutputValue() + const feeValue = Value.onlyCoin(this.fee || Coin.make(0n)) + const requiredValue = Value.add(outputValue, feeValue) + + if (!Value.geq(inputValue, requiredValue)) { + return Eff.fail( + new TxBuilderError({ + message: `Insufficient balance. Required: ${requiredValue}, Available: ${inputValue}` + }) + ) + } + + return Eff.succeed(undefined) + } + + private createChangeOutputs( + _algo: ChangeSelectionAlgo, + changeAddress: AddressEras.AddressEras + ): Eff.Effect, TxBuilderError> { + // Calculate change amount + const inputValue = this.inputs.reduce( + (total: Value.Value, input) => Value.add(total, input.utxoInfo.amount), + Value.onlyCoin(Coin.make(0n)) + ) + + const outputValue = this.calculateOutputValue() + const feeValue = Value.onlyCoin(this.fee || Coin.make(0n)) + const changeValue = Value.subtract(inputValue, Value.add(outputValue, feeValue)) + + // If no change needed, return empty array + const changeAmount = Value.getAda(changeValue) + if (Coin.equals(changeAmount, Coin.make(0n))) { + return Eff.succeed([]) + } + + // Create change output + const changeOutput = TransactionOutput.makeBabbage({ + address: changeAddress as any, // AddressEras includes reward addresses which aren't valid for outputs + amount: changeValue, + datumOption: undefined, + scriptRef: undefined + }) + + return Eff.succeed([ + { + output: changeOutput, + communicationDatum: undefined + } + ]) + } + + private buildTransactionBody( + changeOutputs: Array + ): Eff.Effect { + const allOutputs = [...this.outputs, ...changeOutputs] + + return Eff.succeed( + new TransactionBody.TransactionBody({ + inputs: this.inputs.map((r) => r.input), + outputs: allOutputs.map((r) => r.output), + fee: this.fee || Coin.make(0n), + ttl: this.ttl, + certificates: this.certificates.length > 0 ? (this.certificates.map((c) => c.cert) as any) : undefined, + withdrawals: this.withdrawals.length > 0 ? this.buildWithdrawals() : undefined, + auxiliaryDataHash: this.auxiliaryData ? Hash.hashAuxiliaryData(this.auxiliaryData) : undefined, + validityIntervalStart: this.validityStart, + mint: this.mints.length > 0 ? this.buildMint() : undefined, + scriptDataHash: undefined, // Will be calculated when script data is available + collateralInputs: + this.collateral.length > 0 + ? (this.collateral.map((c) => c.input) as NonEmptyArray) + : undefined, + requiredSigners: + this.requiredSigners.size > 0 + ? (Array.from(this.requiredSigners).map((hex) => KeyHash.fromHex(hex)) as NonEmptyArray) + : undefined, + networkId: this.networkId, + collateralReturn: undefined, // Would be set if using script collateral + totalCollateral: undefined, // Would be calculated based on script execution costs + referenceInputs: + this.referenceInputs.length > 0 + ? (this.referenceInputs.map((r) => r.input) as NonEmptyArray) + : undefined, + votingProcedures: undefined, // Will be implemented when VotingProcedures builder is ready + proposalProcedures: undefined, // Will be implemented when ProposalProcedures builder is ready + currentTreasuryValue: undefined, + donation: undefined + }) + ) + } + + private buildWithdrawals(): Withdrawals.Withdrawals | undefined { + if (this.withdrawals.length === 0) { + return undefined + } + + // Build withdrawals map from withdrawal builder results + const withdrawalMap = new Map() + for (const withdrawal of this.withdrawals) { + withdrawalMap.set(withdrawal.address, withdrawal.amount) + } + + return new Withdrawals.Withdrawals({ withdrawals: withdrawalMap }) + } + + private buildMint(): Mint.Mint | undefined { + if (this.mints.length === 0) { + return undefined + } + + // Combine all mint operations into a single Mint + const mintEntries: Array<[any, any]> = [] + + for (const mintResult of this.mints) { + // Convert assets map to NonZeroInt64 values + const assetEntries: Array<[any, any]> = [] + + for (const [assetName, amount] of mintResult.assets) { + // Only add non-zero amounts + if (amount !== 0n) { + try { + const nonZeroAmount = NonZeroInt64.make(amount.toString()) + assetEntries.push([assetName, nonZeroAmount]) + } catch { + // Skip if amount is zero or invalid + continue + } + } + } + + if (assetEntries.length > 0) { + mintEntries.push([mintResult.policyId, new Map(assetEntries)]) + } + } + + return mintEntries.length > 0 ? Mint.fromEntries(mintEntries) : undefined + } + + private buildWitnessSet( + _body: TransactionBody.TransactionBody + ): Eff.Effect { + // This would normally collect all witness data from inputs, mints, certificates, etc. + // For now, return an empty witness set - actual witnesses would be added during signing + return Eff.succeed( + new TransactionWitnessSet.TransactionWitnessSet({ + vkeyWitnesses: undefined, + nativeScripts: undefined, + bootstrapWitnesses: undefined, + plutusV1Scripts: undefined, + plutusData: undefined, + redeemers: undefined, + plutusV2Scripts: undefined, + plutusV3Scripts: undefined + }) + ) + } + + private estimateTransactionSize(): Eff.Effect { + // Conservative estimate based on typical transaction sizes + // Base size + input size + output size + witness size + const baseSize = 1500 + const inputSize = this.inputs.length * 150 + const outputSize = this.outputs.length * 200 + const witnessSize = this.requiredSigners.size * 100 + + return Eff.succeed(baseSize + inputSize + outputSize + witnessSize) + } + + private calculateScriptFees(): Eff.Effect { + // If no ExUnitPrices are configured, no script fees + if (!this.config.exUnitPrices) { + return Eff.succeed(Coin.make(0n)) + } + + // For now, return 0 fees - proper implementation would need to: + // 1. Collect ExUnits from all redeemers (inputs, mints, certificates, withdrawals) + // 2. Sum up the memory and steps + // 3. Calculate fee using the price model + // This requires the redeemer information to be properly tracked + // which would come from the script execution results + + return Eff.succeed(Coin.make(0n)) + } +} + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Create a new TransactionBuilderConfigBuilder using Effect error handling. + * + * @since 2.0.0 + * @category constructors + */ + export const newConfigBuilder = (): Eff.Effect => + Eff.succeed(TransactionBuilderConfigBuilder.new()) + + /** + * Create a new TransactionBuilder using Effect error handling. + * + * @since 2.0.0 + * @category constructors + */ + export const newBuilder = (config: TransactionBuilderConfig): Eff.Effect => + Eff.succeed(TransactionBuilder.new(config)) + + /** + * Create a new TransactionUnspentOutput using Effect error handling. + * + * @since 2.0.0 + * @category constructors + */ + export const newUtxo = ( + input: TransactionInput.TransactionInput, + output: TransactionOutput.TransactionOutput + ): Eff.Effect => Eff.succeed(TransactionUnspentOutput.new(input, output)) +} + +// ============================================================================ +// Root Namespace Functions (Sync API) +// ============================================================================ + +/** + * Create a new TransactionBuilderConfigBuilder. + * + * @since 2.0.0 + * @category constructors + */ +export const newConfigBuilder = (): TransactionBuilderConfigBuilder => TransactionBuilderConfigBuilder.new() + +/** + * Create a new TransactionBuilder. + * + * @since 2.0.0 + * @category constructors + */ +export const newBuilder = (config: TransactionBuilderConfig): TransactionBuilder => TransactionBuilder.new(config) + +/** + * Create a new TransactionUnspentOutput. + * + * @since 2.0.0 + * @category constructors + */ +export const newUtxo = ( + input: TransactionInput.TransactionInput, + output: TransactionOutput.TransactionOutput +): TransactionUnspentOutput => TransactionUnspentOutput.new(input, output) diff --git a/packages/evolution/src/builders/VoteBuilder.ts b/packages/evolution/src/builders/VoteBuilder.ts new file mode 100644 index 00000000..e3b9fa3f --- /dev/null +++ b/packages/evolution/src/builders/VoteBuilder.ts @@ -0,0 +1,316 @@ +import { Data, Effect as Eff } from "effect" + +import type * as PlutusData from "../core/Data.js" +import * as DatumOption from "../core/DatumOption.js" +import type * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import * as ScriptHash from "../core/ScriptHash.js" +import type * as VotingProcedures from "../core/VotingProcedures.js" +import { hashPlutusData } from "../utils/Hash.js" +import type { NativeScriptWitnessInfo, PartialPlutusWitness } from "./WitnessBuilder.js" +import { InputAggregateWitnessData, PlutusScriptWitness, RequiredWitnessSet } from "./WitnessBuilder.js" + +/** + * Error class for VoteBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class VoteBuilderError extends Data.TaggedError("VoteBuilderError")<{ + message?: string + cause?: unknown +}> {} + +// Define a simplified GovernanceActionId type for now +export interface GovActionId { + transactionId: string + govActionIndex: bigint +} + +// Define a simplified VotingProcedure type for now +export interface VotingProcedure { + vote: "No" | "Yes" | "Abstain" + anchor?: string +} + +/** + * Result of building votes + * + * @since 2.0.0 + * @category model + */ +export interface VoteBuilderResult { + votes: Map> + requiredWits: RequiredWitnessSet + aggregateWitnesses: Array +} + +/** + * Builder for governance votes + * + * @since 2.0.0 + * @category builders + */ +export class VoteBuilder { + private result: VoteBuilderResult + + constructor() { + this.result = { + votes: new Map(), + requiredWits: RequiredWitnessSet.default(), + aggregateWitnesses: [] + } + } + + static new(): VoteBuilder { + return new VoteBuilder() + } + + withVote( + voter: VotingProcedures.Voter, + govActionId: GovActionId, + procedure: VotingProcedure + ): Eff.Effect { + return Eff.gen( + function* (this: VoteBuilder) { + const keyHash = getVoterKeyHash(voter) + if (!keyHash) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Voter is script. Call withPlutusVote() instead." + }) + ) + } + + this.result.requiredWits.addVkeyKeyHash(keyHash) + + // Check for existing vote + const voterVotes = this.result.votes.get(voter) + if (voterVotes?.has(govActionId)) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Vote already exists" + }) + ) + } + + if (!voterVotes) { + this.result.votes.set(voter, new Map([[govActionId, procedure]])) + } else { + voterVotes.set(govActionId, procedure) + } + + return this + }.bind(this) + ) + } + + withNativeScriptVote( + voter: VotingProcedures.Voter, + govActionId: GovActionId, + procedure: VotingProcedure, + nativeScript: NativeScripts.NativeScript, + witnessInfo: NativeScriptWitnessInfo + ): Eff.Effect { + return Eff.gen( + function* (this: VoteBuilder) { + const voterScriptHash = getVoterScriptHash(voter) + const scriptHash = ScriptHash.fromScript(nativeScript) + + if (!voterScriptHash) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Voter is key hash. Call withVote() instead." + }) + ) + } + + if (!ScriptHash.equals(voterScriptHash, scriptHash)) { + const errRequiredWits = RequiredWitnessSet.default() + errRequiredWits.addScriptHash(voterScriptHash) + return yield* Eff.fail( + new VoteBuilderError({ + message: "Missing the following witnesses for the vote", + cause: errRequiredWits + }) + ) + } + + this.result.requiredWits.addScriptHash(voterScriptHash) + + // Check for existing vote + const voterVotes = this.result.votes.get(voter) + if (voterVotes?.has(govActionId)) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Vote already exists" + }) + ) + } + + if (!voterVotes) { + this.result.votes.set(voter, new Map([[govActionId, procedure]])) + } else { + voterVotes.set(govActionId, procedure) + } + + this.result.aggregateWitnesses.push(InputAggregateWitnessData.nativeScript(nativeScript, witnessInfo)) + + return this + }.bind(this) + ) + } + + withPlutusVote( + voter: VotingProcedures.Voter, + govActionId: GovActionId, + procedure: VotingProcedure, + partialWitness: PartialPlutusWitness, + requiredSigners: Array, + datum: PlutusData.Data + ): Eff.Effect { + return this.withPlutusVoteImpl(voter, govActionId, procedure, partialWitness, requiredSigners, datum) + } + + withPlutusVoteInlineDatum( + voter: VotingProcedures.Voter, + govActionId: GovActionId, + procedure: VotingProcedure, + partialWitness: PartialPlutusWitness, + requiredSigners: Array + ): Eff.Effect { + return this.withPlutusVoteImpl(voter, govActionId, procedure, partialWitness, requiredSigners, undefined) + } + + private withPlutusVoteImpl( + voter: VotingProcedures.Voter, + govActionId: GovActionId, + procedure: VotingProcedure, + partialWitness: PartialPlutusWitness, + requiredSigners: Array, + datum?: PlutusData.Data + ): Eff.Effect { + return Eff.gen( + function* (this: VoteBuilder) { + const requiredWits = RequiredWitnessSet.default() + requiredSigners.forEach((signer) => requiredWits.addVkeyKeyHash(signer)) + + const voterScriptHash = getVoterScriptHash(voter) + if (!voterScriptHash) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Voter is key hash. Call withVote() instead." + }) + ) + } + + requiredWits.addScriptHash(voterScriptHash) + const requiredWitsLeft = structuredClone(requiredWits) + + // Clear vkeys as we don't know which ones will be used + const clearedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: [], // Cleared + bootstraps: requiredWitsLeft.bootstraps, + scripts: requiredWitsLeft.scripts, + plutusData: requiredWitsLeft.plutusData, + redeemers: requiredWitsLeft.redeemers, + scriptRefs: requiredWitsLeft.scriptRefs + }) + + const scriptHash = PlutusScriptWitness.hash(partialWitness.scriptWitness) + + // Remove the script hash + const filteredScripts = clearedRequiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const updatedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: clearedRequiredWitsLeft.vkeys, + bootstraps: clearedRequiredWitsLeft.bootstraps, + scripts: filteredScripts, + plutusData: clearedRequiredWitsLeft.plutusData, + redeemers: clearedRequiredWitsLeft.redeemers, + scriptRefs: clearedRequiredWitsLeft.scriptRefs + }) + + // Remove datum hash if provided + let finalRequiredWitsLeft = updatedRequiredWitsLeft + if (datum) { + const datumHash = hashPlutusData(datum) + const filteredPlutusData = updatedRequiredWitsLeft.plutusData.filter((h) => !DatumOption.equals(h, datumHash)) + finalRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: updatedRequiredWitsLeft.vkeys, + bootstraps: updatedRequiredWitsLeft.bootstraps, + scripts: updatedRequiredWitsLeft.scripts, + plutusData: filteredPlutusData, + redeemers: updatedRequiredWitsLeft.redeemers, + scriptRefs: updatedRequiredWitsLeft.scriptRefs + }) + } + + if (finalRequiredWitsLeft.len() > 0) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Missing the following witnesses for the vote", + cause: finalRequiredWitsLeft + }) + ) + } + + // Check for existing vote + const voterVotes = this.result.votes.get(voter) + if (voterVotes?.has(govActionId)) { + return yield* Eff.fail( + new VoteBuilderError({ + message: "Vote already exists" + }) + ) + } + + if (!voterVotes) { + this.result.votes.set(voter, new Map([[govActionId, procedure]])) + } else { + voterVotes.set(govActionId, procedure) + } + + this.result.requiredWits.addAll(requiredWits) + this.result.aggregateWitnesses.push( + InputAggregateWitnessData.plutusScript(partialWitness, requiredSigners, datum) + ) + + return this + }.bind(this) + ) + } + + build(): VoteBuilderResult { + return this.result + } +} + +/** + * Helper function to get key hash from a voter + * Returns undefined if voter uses script hash + */ +function getVoterKeyHash(voter: VotingProcedures.Voter): KeyHash.KeyHash | undefined { + // Extract KeyHash from voter credential + if (voter._tag === "ConstitutionalCommitteeVoter" && voter.credential._tag === "KeyHash") { + return voter.credential + } + if (voter._tag === "DRepVoter" && voter.drep._tag === "KeyHashDRep") { + return voter.drep.keyHash + } + return undefined +} + +/** + * Helper function to get script hash from a voter + * Returns undefined if voter uses key hash + */ +function getVoterScriptHash(voter: VotingProcedures.Voter): ScriptHash.ScriptHash | undefined { + // Extract ScriptHash from voter credential + if (voter._tag === "ConstitutionalCommitteeVoter" && voter.credential._tag === "ScriptHash") { + return voter.credential + } + if (voter._tag === "DRepVoter" && voter.drep._tag === "ScriptHashDRep") { + return voter.drep.scriptHash + } + return undefined +} diff --git a/packages/evolution/src/builders/WithdrawalBuilder.ts b/packages/evolution/src/builders/WithdrawalBuilder.ts new file mode 100644 index 00000000..8c680dc8 --- /dev/null +++ b/packages/evolution/src/builders/WithdrawalBuilder.ts @@ -0,0 +1,195 @@ +import { Data, Effect as Eff } from "effect" + +import type * as Coin from "../core/Coin.js" +import type * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import type * as RewardAccount from "../core/RewardAccount.js" +import * as ScriptHash from "../core/ScriptHash.js" +import type { NativeScriptWitnessInfo, PartialPlutusWitness } from "./WitnessBuilder.js" +import { InputAggregateWitnessData, PlutusScriptWitness, RequiredWitnessSet } from "./WitnessBuilder.js" + +/** + * Error class for WithdrawalBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class WithdrawalBuilderError extends Data.TaggedError("WithdrawalBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Calculates required witnesses for a withdrawal + * + * @since 2.0.0 + * @category utils + */ +export function withdrawalRequiredWits( + address: RewardAccount.RewardAccount, + requiredWitnesses: RequiredWitnessSet +): void { + const credential = address.stakeCredential + + switch (credential._tag) { + case "KeyHash": + requiredWitnesses.addVkeyKeyHash(credential) + break + case "ScriptHash": + requiredWitnesses.addScriptHash(credential) + break + } +} + +/** + * Result of building a withdrawal + * + * @since 2.0.0 + * @category model + */ +export interface WithdrawalBuilderResult { + address: RewardAccount.RewardAccount + amount: Coin.Coin + aggregateWitness?: InputAggregateWitnessData + requiredWits: RequiredWitnessSet +} + +/** + * Builder for a single withdrawal + * + * @since 2.0.0 + * @category builders + */ +export class SingleWithdrawalBuilder { + constructor( + public readonly address: RewardAccount.RewardAccount, + public readonly amount: Coin.Coin + ) {} + + static new(address: RewardAccount.RewardAccount, amount: Coin.Coin): SingleWithdrawalBuilder { + return new SingleWithdrawalBuilder(address, amount) + } + + paymentKey(): Eff.Effect { + return Eff.gen( + function* (this: SingleWithdrawalBuilder) { + const requiredWits = RequiredWitnessSet.default() + withdrawalRequiredWits(this.address, requiredWits) + + if (requiredWits.scripts.length > 0) { + return yield* Eff.fail( + new WithdrawalBuilderError({ + message: "Withdrawal required a script, not a payment key" + }) + ) + } + + return { + address: this.address, + amount: this.amount, + aggregateWitness: undefined, + requiredWits + } + }.bind(this) + ) + } + + nativeScript( + nativeScript: NativeScripts.NativeScript, + witnessInfo: NativeScriptWitnessInfo + ): Eff.Effect { + return Eff.gen( + function* (this: SingleWithdrawalBuilder) { + const requiredWits = RequiredWitnessSet.default() + withdrawalRequiredWits(this.address, requiredWits) + const requiredWitsLeft = structuredClone(requiredWits) + + const scriptHash = ScriptHash.fromScript(nativeScript) + + // Remove the script hash from required witnesses + const filteredScripts = requiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const finalRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: requiredWitsLeft.vkeys, + bootstraps: requiredWitsLeft.bootstraps, + scripts: filteredScripts, + plutusData: requiredWitsLeft.plutusData, + redeemers: requiredWitsLeft.redeemers, + scriptRefs: requiredWitsLeft.scriptRefs + }) + + if (finalRequiredWitsLeft.scripts.length > 0) { + return yield* Eff.fail( + new WithdrawalBuilderError({ + message: "Missing the following witnesses for the withdrawal", + cause: finalRequiredWitsLeft + }) + ) + } + + return { + address: this.address, + amount: this.amount, + aggregateWitness: InputAggregateWitnessData.nativeScript(nativeScript, witnessInfo), + requiredWits + } + }.bind(this) + ) + } + + plutusScript( + partialWitness: PartialPlutusWitness, + requiredSigners: Array + ): Eff.Effect { + return Eff.gen( + function* (this: SingleWithdrawalBuilder) { + const requiredWits = RequiredWitnessSet.default() + requiredSigners.forEach((signer) => requiredWits.addVkeyKeyHash(signer)) + withdrawalRequiredWits(this.address, requiredWits) + const requiredWitsLeft = structuredClone(requiredWits) + + // Clear vkeys as we don't know which ones will be used + const clearedRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: [], // Cleared + bootstraps: requiredWitsLeft.bootstraps, + scripts: requiredWitsLeft.scripts, + plutusData: requiredWitsLeft.plutusData, + redeemers: requiredWitsLeft.redeemers, + scriptRefs: requiredWitsLeft.scriptRefs + }) + + const scriptHash = PlutusScriptWitness.hash(partialWitness.scriptWitness) + + // Remove the script hash + const filteredScripts = clearedRequiredWitsLeft.scripts.filter((h) => !ScriptHash.equals(h, scriptHash)) + const finalRequiredWitsLeft = new RequiredWitnessSet({ + vkeys: clearedRequiredWitsLeft.vkeys, + bootstraps: clearedRequiredWitsLeft.bootstraps, + scripts: filteredScripts, + plutusData: clearedRequiredWitsLeft.plutusData, + redeemers: clearedRequiredWitsLeft.redeemers, + scriptRefs: clearedRequiredWitsLeft.scriptRefs + }) + + if (finalRequiredWitsLeft.len() > 0) { + return yield* Eff.fail( + new WithdrawalBuilderError({ + message: "Missing the following witnesses for the withdrawal", + cause: finalRequiredWitsLeft + }) + ) + } + + return { + address: this.address, + amount: this.amount, + aggregateWitness: InputAggregateWitnessData.plutusScript( + partialWitness, + requiredSigners, + undefined // No datum for withdrawals + ), + requiredWits + } + }.bind(this) + ) + } +} diff --git a/packages/evolution/src/builders/WitnessBuilder.ts b/packages/evolution/src/builders/WitnessBuilder.ts new file mode 100644 index 00000000..efe344c8 --- /dev/null +++ b/packages/evolution/src/builders/WitnessBuilder.ts @@ -0,0 +1,242 @@ +import { Data, Schema } from "effect" + +import * as ByronAddress from "../core/ByronAddress.js" +import type * as PlutusData from "../core/Data.js" +import * as DatumOption from "../core/DatumOption.js" +import * as KeyHash from "../core/KeyHash.js" +import type * as NativeScripts from "../core/NativeScripts.js" +import type * as PlutusV1 from "../core/PlutusV1.js" +import type * as PlutusV2 from "../core/PlutusV2.js" +import type * as PlutusV3 from "../core/PlutusV3.js" +import * as Redeemer from "../core/Redeemer.js" +import * as ScriptHash from "../core/ScriptHash.js" + +/** + * Error class for WitnessBuilder related operations. + * + * @since 2.0.0 + * @category errors + */ +export class WitnessBuilderError extends Data.TaggedError("WitnessBuilderError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Redeemer witness key for identifying redeemers by tag and index. + * + * @since 2.0.0 + * @category model + */ +export class RedeemerWitnessKey extends Schema.Class("RedeemerWitnessKey")({ + tag: Redeemer.RedeemerTag, + index: Schema.BigInt.annotations({ + identifier: "RedeemerWitnessKey.Index", + description: "Index into the respective transaction array" + }) +}) { + static new(tag: Redeemer.RedeemerTag, index: bigint): RedeemerWitnessKey { + return new RedeemerWitnessKey({ tag, index }) + } +} + +/** + * Required witness set tracking what witnesses are needed + * + * @since 2.0.0 + * @category model + */ +export class RequiredWitnessSet extends Schema.Class("RequiredWitnessSet")({ + vkeys: Schema.Array(KeyHash.KeyHash), + bootstraps: Schema.Array(ByronAddress.ByronAddress), + scripts: Schema.Array(ScriptHash.ScriptHash), + plutusData: Schema.Array(DatumOption.DatumHash), + redeemers: Schema.Array(RedeemerWitnessKey), + scriptRefs: Schema.Array(ScriptHash.ScriptHash) +}) { + static default(): RequiredWitnessSet { + return new RequiredWitnessSet({ + vkeys: [], + bootstraps: [], + scripts: [], + plutusData: [], + redeemers: [], + scriptRefs: [] + }) + } + + addVkeyKeyHash(hash: KeyHash.KeyHash): void { + if (!this.vkeys.find((h) => KeyHash.equals(h, hash))) { + ;(this.vkeys as Array).push(hash) + } + } + + addBootstrap(address: ByronAddress.ByronAddress): void { + if (!this.bootstraps.find((a) => ByronAddress.equals(a, address))) { + ;(this.bootstraps as Array).push(address) + } + } + + addScriptHash(hash: ScriptHash.ScriptHash): void { + // Check if it's already in script refs + if (!this.scriptRefs.find((h) => ScriptHash.equals(h, hash))) { + if (!this.scripts.find((h) => ScriptHash.equals(h, hash))) { + ;(this.scripts as Array).push(hash) + } + } + } + + addScriptRef(hash: ScriptHash.ScriptHash): void { + // Remove from scripts if present + ;(this as any).scripts = this.scripts.filter((h) => !ScriptHash.equals(h, hash)) + if (!this.scriptRefs.find((h) => ScriptHash.equals(h, hash))) { + ;(this.scriptRefs as Array).push(hash) + } + } + + addPlutusDataHash(hash: DatumOption.DatumHash): void { + if (!this.plutusData.find((h) => DatumOption.equals(h, hash))) { + ;(this.plutusData as Array).push(hash) + } + } + + addRedeemerTag(redeemer: RedeemerWitnessKey): void { + if (!this.redeemers.find((r) => r.tag === redeemer.tag && r.index === redeemer.index)) { + ;(this.redeemers as Array).push(redeemer) + } + } + + addAll(requirements: RequiredWitnessSet): void { + requirements.vkeys.forEach((vkey) => this.addVkeyKeyHash(vkey)) + requirements.bootstraps.forEach((bootstrap) => this.addBootstrap(bootstrap)) + requirements.scripts.forEach((script) => this.addScriptHash(script)) + requirements.plutusData.forEach((data) => this.addPlutusDataHash(data)) + requirements.redeemers.forEach((redeemer) => this.addRedeemerTag(redeemer)) + requirements.scriptRefs.forEach((ref) => this.addScriptRef(ref)) + } + + len(): number { + return ( + this.vkeys.length + + this.bootstraps.length + + this.scripts.length + + this.plutusData.length + + this.redeemers.length + + this.scriptRefs.length + ) + } +} + +/** + * Native script witness info + * + * @since 2.0.0 + * @category model + */ +export type NativeScriptWitnessInfo = + | { type: "Count"; num: number } + | { type: "Vkeys"; vkeys: Array } + | { type: "AssumeWorst" } + +export const NativeScriptWitnessInfo = { + numSignatures(num: number): NativeScriptWitnessInfo { + return { type: "Count", num } + }, + + vkeys(vkeys: Array): NativeScriptWitnessInfo { + return { type: "Vkeys", vkeys } + }, + + assumeSignatureCount(): NativeScriptWitnessInfo { + return { type: "AssumeWorst" } + } +} + +/** + * Plutus script witness + * + * @since 2.0.0 + * @category model + */ +export type PlutusScriptWitness = + | { type: "Ref"; hash: ScriptHash.ScriptHash } + | { type: "Script"; script: PlutusV1.PlutusV1 | PlutusV2.PlutusV2 | PlutusV3.PlutusV3 } + +export const PlutusScriptWitness = { + ref(hash: ScriptHash.ScriptHash): PlutusScriptWitness { + return { type: "Ref", hash } + }, + + script(script: PlutusV1.PlutusV1 | PlutusV2.PlutusV2 | PlutusV3.PlutusV3): PlutusScriptWitness { + return { type: "Script", script } + }, + + hash(witness: PlutusScriptWitness): ScriptHash.ScriptHash { + switch (witness.type) { + case "Ref": + return witness.hash + case "Script": + // Use ScriptHash.fromScript to compute the hash + return ScriptHash.fromScript(witness.script) + } + } +} + +/** + * Partial plutus witness + * + * @since 2.0.0 + * @category model + */ +export class PartialPlutusWitness extends Schema.Class("PartialPlutusWitness")({ + script: Schema.Any, // PlutusScriptWitness + redeemer: Schema.Any // PlutusData.Data +}) { + static new(script: PlutusScriptWitness, redeemer: PlutusData.Data): PartialPlutusWitness { + return new PartialPlutusWitness({ script: script as any, redeemer }) + } + + get scriptWitness(): PlutusScriptWitness { + return this.script as PlutusScriptWitness + } + + get redeemerData(): PlutusData.Data { + return this.redeemer as PlutusData.Data + } +} + +/** + * Aggregate witness data for inputs + * + * @since 2.0.0 + * @category model + */ +export type InputAggregateWitnessData = + | { type: "NativeScript"; script: NativeScripts.NativeScript; info: NativeScriptWitnessInfo } + | { + type: "PlutusScript" + witness: PartialPlutusWitness + requiredSigners: Array + datum?: PlutusData.Data + } + +export const InputAggregateWitnessData = { + nativeScript(script: NativeScripts.NativeScript, info: NativeScriptWitnessInfo): InputAggregateWitnessData { + return { type: "NativeScript", script, info } + }, + + plutusScript( + witness: PartialPlutusWitness, + requiredSigners: Array, + datum?: PlutusData.Data + ): InputAggregateWitnessData { + return { type: "PlutusScript", witness, requiredSigners, datum } + }, + + redeemerPlutusData(data: InputAggregateWitnessData): PlutusData.Data | undefined { + if (data.type === "PlutusScript") { + return data.witness.redeemer + } + return undefined + } +} diff --git a/packages/evolution/src/builders/index.ts b/packages/evolution/src/builders/index.ts new file mode 100644 index 00000000..8aef0aca --- /dev/null +++ b/packages/evolution/src/builders/index.ts @@ -0,0 +1,38 @@ +/** + * Transaction builder modules for creating transaction components with witness information + * + * @since 2.0.0 + */ + +// Core witness building utilities +export * from "./WitnessBuilder.js" + +// Input builder for transaction inputs +export * from "./InputBuilder.js" + +// Mint builder for minting operations +export * from "./MintBuilder.js" + +// Withdrawal builder for stake reward withdrawals +export * from "./WithdrawalBuilder.js" + +// Certificate builder for stake pool and delegation certificates +export * from "./CertificateBuilder.js" + +// Proposal builder for governance proposals +export * from "./ProposalBuilder.js" + +// Vote builder for governance votes +export * from "./VoteBuilder.js" + +// Redeemer builder for Plutus script redeemers +export * from "./RedeemerBuilder.js" + +// Output builder for transaction outputs +export * from "./OutputBuilder.js" + +// Transaction builder for complete transactions +export * from "./TxBuilder.js" + +// Builder utilities +export * from "./utils/index.js" diff --git a/packages/evolution/src/builders/utils/MinAda.ts b/packages/evolution/src/builders/utils/MinAda.ts new file mode 100644 index 00000000..b11ba32d --- /dev/null +++ b/packages/evolution/src/builders/utils/MinAda.ts @@ -0,0 +1,178 @@ +import { Data, Effect as Eff } from "effect" + +import type * as Coin from "../../core/Coin.js" +import * as TransactionOutput from "../../core/TransactionOutput.js" +import * as Value from "../../core/Value.js" + +/** + * Error class for MinAda calculation related operations. + * + * @since 2.0.0 + * @category errors + */ +export class MinAdaError extends Data.TaggedError("MinAdaError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Calculate the CBOR encoding size for a coin value. + * Based on CBOR specification for unsigned integers. + * This matches the `fit_sz` function in CML Rust implementation. + * + * @since 2.0.0 + * @category utils + */ +const getCoinCborSize = (coin: Coin.Coin): number => { + const value = coin + + // CBOR unsigned integer encoding: + // - 0-23: direct encoding (1 byte total including type) + // - 24-255: 1 byte + 1 byte value = 2 bytes + // - 256-65535: 1 byte + 2 byte value = 3 bytes + // - 65536-4294967295: 1 byte + 4 byte value = 5 bytes + // - Above: 1 byte + 8 byte value = 9 bytes + if (value <= 23n) return 1 + if (value <= 255n) return 2 + if (value <= 65535n) return 3 + if (value <= 4294967295n) return 5 + return 9 +} + +/** + * Calculate minimum ADA required for a transaction output. + * Direct port of the Rust implementation from cardano-multiplatform-lib. + * + * Algorithm matches CML's min_ada.rs: + * 1. Calculate CBOR size of the output + * 2. Add 160-byte constant overhead (from Babbage spec figure 5) + * 3. Use iterative approach to handle coin size changes affecting CBOR encoding + * 4. Multiply total size by coins_per_utxo_byte protocol parameter + * + * @since 2.0.0 + * @category calculations + */ +export const minAdaRequired = ( + output: TransactionOutput.TransactionOutput, + coinsPerUtxoByte: Coin.Coin +): Eff.Effect => + Eff.gen(function* () { + try { + // Get CBOR size of the output (matches output.to_cbor_bytes().len()) + const outputCborBytes = yield* Eff.try({ + try: () => TransactionOutput.toCBORBytes(output), + catch: (cause) => + new MinAdaError({ + message: "Failed to serialize output to CBOR", + cause + }) + }) + + const outputSize = outputCborBytes.length + + // Constant from figure 5 in Babbage spec meant to represent the size the input in a UTXO + const constantOverhead = 160 + + // Extract current coin amount from the output + const currentCoin = Value.getAda(output.amount) + + // How many bytes the Coin part of the Value will take (matches old_coin_size calculation) + const oldCoinSize = getCoinCborSize(currentCoin) + + // Most recent estimate of the size in bytes to include the minimum ADA value + let latestSize = oldCoinSize + + // We calculate min ada in a loop because every time we increase the min ADA, + // it may increase the CBOR size in bytes + let tentativeMinAda: Coin.Coin + + while (true) { + const sizeDiff = latestSize - oldCoinSize + + // Calculate tentative minimum ADA + const totalSizeForCalc = outputSize + constantOverhead + sizeDiff + + // Check for overflow (matches the Rust checked_mul logic) + if (totalSizeForCalc < 0 || totalSizeForCalc > Number.MAX_SAFE_INTEGER) { + return yield* Eff.fail( + new MinAdaError({ + message: "Integer overflow in minimum ADA calculation" + }) + ) + } + + tentativeMinAda = BigInt(totalSizeForCalc) * coinsPerUtxoByte + + // Calculate new coin CBOR size (matches new_coin_size calculation) + const newCoinSize = getCoinCborSize(tentativeMinAda) + + // Check if we've converged + const isDone = latestSize === newCoinSize + latestSize = newCoinSize + + if (isDone) { + break + } + } + + // How many bytes the size changed from including the minimum ADA value + const sizeChange = latestSize - oldCoinSize + + // Final calculation with converged size + const finalTotalSize = outputSize + constantOverhead + sizeChange + + // Check for overflow again + if (finalTotalSize < 0 || finalTotalSize > Number.MAX_SAFE_INTEGER) { + return yield* Eff.fail( + new MinAdaError({ + message: "Integer overflow in final minimum ADA calculation" + }) + ) + } + + const adjustedMinAda = BigInt(finalTotalSize) * coinsPerUtxoByte + + return adjustedMinAda + } catch (error) { + return yield* Eff.fail( + new MinAdaError({ + message: "Unexpected error in minimum ADA calculation", + cause: error + }) + ) + } + }) + +/** + * Calculate minimum ADA required for a transaction output (sync version). + * + * @since 2.0.0 + * @category calculations + */ +export const minAdaRequiredSync = ( + output: TransactionOutput.TransactionOutput, + coinsPerUtxoByte: Coin.Coin +): Coin.Coin => Eff.runSync(minAdaRequired(output, coinsPerUtxoByte)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace MinAdaEffect { + /** + * Calculate minimum ADA required for a transaction output using Effect error handling. + * + * @since 2.0.0 + * @category calculations + */ + export const minAdaRequired = ( + output: TransactionOutput.TransactionOutput, + coinsPerUtxoByte: Coin.Coin + ): Eff.Effect => minAdaRequired(output, coinsPerUtxoByte) +} diff --git a/packages/evolution/src/builders/utils/index.ts b/packages/evolution/src/builders/utils/index.ts new file mode 100644 index 00000000..b2f0a2e6 --- /dev/null +++ b/packages/evolution/src/builders/utils/index.ts @@ -0,0 +1 @@ +export * from "./MinAda.js"