From b15f52e195e779f19af8a6ee5ad08e6189bf3b83 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 01/10] feat(provider): keep the bytes getDatum fetched --- packages/evolution/src/sdk/provider/Provider.ts | 4 +++- .../src/sdk/provider/internal/BlockfrostEffect.ts | 8 +++++++- .../evolution/src/sdk/provider/internal/KoiosEffect.ts | 6 +++++- .../evolution/src/sdk/provider/internal/KupmiosEffects.ts | 6 +++++- .../evolution/src/sdk/provider/internal/MaestroEffect.ts | 6 +++++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/evolution/src/sdk/provider/Provider.ts b/packages/evolution/src/sdk/provider/Provider.ts index d75dc3dd9..cc125b4b6 100644 --- a/packages/evolution/src/sdk/provider/Provider.ts +++ b/packages/evolution/src/sdk/provider/Provider.ts @@ -110,7 +110,9 @@ export interface ProviderEffect { readonly getDelegation: (rewardAddress: RewardAddress.RewardAddress) => Effect.Effect /** * Query a datum by its hash. - * Returns the parsed PlutusData structure. + * Returns the parsed PlutusData structure. The built-in providers record + * the bytes it was fetched with (`OriginalBytes`), so a witness set writes + * the datum with the bytes its hash covers. */ readonly getDatum: (datumHash: DatumHash.DatumHash) => Effect.Effect /** diff --git a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts index 6bb9782b7..b33d2e384 100644 --- a/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts @@ -14,6 +14,7 @@ import * as DatumHash from "../../../DatumHash.js" import type * as DatumOption from "../../../DatumOption.js" import * as InlineDatum from "../../../InlineDatum.js" import * as NativeScripts from "../../../NativeScripts.js" +import * as OriginalBytes from "../../../OriginalBytes.js" import * as PlutusV1 from "../../../PlutusV1.js" import * as PlutusV2 from "../../../PlutusV2.js" import * as PlutusV3 from "../../../PlutusV3.js" @@ -609,7 +610,12 @@ export const getDatum = (baseUrl: string, projectId?: string) => (datumHash: Dat Effect.flatMap((datum) => { // Parse CBOR hex to PlutusData return Effect.try({ - try: () => PlutusData.fromCBORHex(datum.cbor), + try: () => { + // Keep the fetched bytes: a witness set writes the datum with them + const bytes = Bytes.fromHex(datum.cbor) + const data = PlutusData.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) + }, catch: (error) => new ProviderError({ message: "Failed to parse datum CBOR", cause: error }) }) }), diff --git a/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts b/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts index 9d9c4a8f4..fac804ecc 100644 --- a/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/KoiosEffect.ts @@ -6,6 +6,7 @@ import * as Bytes from "../../../Bytes.js" import * as Credential from "../../../Credential.js" import * as PlutusData from "../../../Data.js" import type * as DatumHash from "../../../DatumHash.js" +import * as OriginalBytes from "../../../OriginalBytes.js" import * as PolicyId from "../../../PolicyId.js" import * as PoolKeyHash from "../../../PoolKeyHash.js" import * as Redeemer from "../../../Redeemer.js" @@ -249,7 +250,10 @@ export const getDatum = (baseUrl: string, token?: string) => (datumHash: DatumHa Effect.catchAll(wrapError("getDatum")) ) - return PlutusData.fromCBORHex(result.bytes) + // Keep the fetched bytes: a witness set writes the datum with them + const bytes = Bytes.fromHex(result.bytes) + const data = PlutusData.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) }) export const awaitTx = diff --git a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts index 70a3ff137..914e5b17f 100644 --- a/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts +++ b/packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts @@ -9,6 +9,7 @@ import * as DatumHash from "../../../DatumHash.js" import type * as DatumOption from "../../../DatumOption.js" import * as InlineDatum from "../../../InlineDatum.js" import * as NativeScripts from "../../../NativeScripts.js" +import * as OriginalBytes from "../../../OriginalBytes.js" import * as PlutusV1 from "../../../PlutusV1.js" import * as PlutusV2 from "../../../PlutusV2.js" import * as PlutusV3 from "../../../PlutusV3.js" @@ -468,5 +469,8 @@ export const getDatumEffect = (kupoUrl: string, headers?: { kupoHeader?: Record< Effect.flatMap(Effect.fromNullable), Effect.catchAll(wrapError("getDatum")) ) - return PlutusData.fromCBORHex(result.datum) + // Keep the fetched bytes: a witness set writes the datum with them + const bytes = Bytes.fromHex(result.datum) + const data = PlutusData.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) }) diff --git a/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts b/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts index 3e5bd15c3..e8a710a79 100644 --- a/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts +++ b/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts @@ -10,6 +10,7 @@ import * as Bytes from "../../../Bytes.js" import type * as Credential from "../../../Credential.js" import * as PlutusData from "../../../Data.js" import type * as DatumHash from "../../../DatumHash.js" +import * as OriginalBytes from "../../../OriginalBytes.js" import * as Script from "../../../Script.js" import * as ScriptRef from "../../../ScriptRef.js" import * as Transaction from "../../../Transaction.js" @@ -307,7 +308,10 @@ export const getDatum = (baseUrl: string, apiKey: string) => (datumHash: DatumHa } ).pipe(Effect.timeout(TIMEOUT), Effect.catchAll(wrapError("get datum"))) - return PlutusData.fromCBORHex(response.data.bytes) + // Keep the fetched bytes: a witness set writes the datum with them + const bytes = Bytes.fromHex(response.data.bytes) + const data = PlutusData.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) }) /** From a5ed1364462e326ed482bbb94e5b6463f92d5b79 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 02/10] feat(witness-set): write witness datums with their recorded bytes The witness set and the script data hash write a datum's recorded bytes ahead of the plutusData options, and the witness set keeps each datum as the same object. --- packages/evolution/src/CBOR.ts | 6 ++++-- packages/evolution/src/Redeemers.ts | 6 +++++- .../evolution/src/TransactionWitnessSet.ts | 19 +++++++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index c64b8ad58..985d1b84f 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -381,8 +381,10 @@ export const CARDANO_NODE_DATA_OPTIONS: CodecOptions = CML_DATA_DEFINITE_OPTIONS * * - `ledger`: the options for ledger structures: the body, the witness set, * and the containers that hold datums and redeemers. - * - `plutusData`: the options for Plutus data items: new inline datums, - * witness datums and redeemer data. + * - `plutusData`: the options for Plutus data items: new inline datums, new + * witness datums and redeemer data. A datum that keeps the bytes it was + * decoded from, such as one from `provider.getDatum`, is written with + * those bytes. * * @since 2.0.0 * @category model diff --git a/packages/evolution/src/Redeemers.ts b/packages/evolution/src/Redeemers.ts index 9ecd0feb9..3975d0614 100644 --- a/packages/evolution/src/Redeemers.ts +++ b/packages/evolution/src/Redeemers.ts @@ -4,6 +4,7 @@ import { Effect as Eff, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema import * as CBOR from "./CBOR.js" import * as CostModel from "./CostModel.js" import * as Data from "./Data.js" +import * as OriginalBytes from "./OriginalBytes.js" import * as Redeemer from "./Redeemer.js" import * as ScriptDataHash from "./ScriptDataHash.js" @@ -649,7 +650,10 @@ const encodeDatumsTaggedSet = (datums: ReadonlyArray, options: CBOR.T CBOR.encodeTaggedValue( 258, encodeArray( - datums.map((datum) => Data.toCBORBytes(datum, options.plutusData)), + datums.map( + (datum) => + (typeof datum === "bigint" ? undefined : OriginalBytes.get(datum)) ?? Data.toCBORBytes(datum, options.plutusData) + ), options.ledger ) ) diff --git a/packages/evolution/src/TransactionWitnessSet.ts b/packages/evolution/src/TransactionWitnessSet.ts index 0a95efbff..dcc5bdd9e 100644 --- a/packages/evolution/src/TransactionWitnessSet.ts +++ b/packages/evolution/src/TransactionWitnessSet.ts @@ -6,6 +6,7 @@ import * as CBOR from "./CBOR.js" import * as PlutusData from "./Data.js" import * as Ed25519Signature from "./Ed25519Signature.js" import * as NativeScripts from "./NativeScripts.js" +import * as OriginalBytes from "./OriginalBytes.js" import * as PlutusV1 from "./PlutusV1.js" import * as PlutusV2 from "./PlutusV2.js" import * as PlutusV3 from "./PlutusV3.js" @@ -178,12 +179,22 @@ export type PlutusScript = typeof PlutusScript.Type * @since 2.0.0 * @category model */ +// A datum passes the constructor as the same object, so the bytes recorded +// for it stay with it when a witness set is built from another one. The +// encoded side stays DataSchema's +const isData = Schema.is(PlutusData.DataSchema) +const WitnessDatum = Schema.transform( + PlutusData.DataSchema, + Schema.declare((input: unknown): input is PlutusData.Data => isData(input)), + { strict: true, decode: (datum) => datum, encode: (datum) => datum } +).annotations({ identifier: "TransactionWitnessSet.WitnessDatum" }) + export class TransactionWitnessSet extends Schema.Class("TransactionWitnessSet")({ vkeyWitnesses: Schema.optional(Schema.Array(VKeyWitness)), nativeScripts: Schema.optional(Schema.Array(NativeScripts.NativeScript)), bootstrapWitnesses: Schema.optional(Schema.Array(Bootstrap.BootstrapWitness)), plutusV1Scripts: Schema.optional(Schema.Array(PlutusV1.PlutusV1)), - plutusData: Schema.optional(Schema.Array(PlutusData.DataSchema)), + plutusData: Schema.optional(Schema.Array(WitnessDatum)), redeemers: Schema.optional(Schema.typeSchema(Redeemers.Redeemers)), plutusV2Scripts: Schema.optional(Schema.Array(PlutusV2.PlutusV2)), plutusV3Scripts: Schema.optional(Schema.Array(PlutusV3.PlutusV3)) @@ -571,7 +582,11 @@ const encodeDatumSet = (datums: ReadonlyArray, options: CBOR.Tx CBOR.encodeTaggedValue( 258, encodeArray( - datums.map((datum) => PlutusData.toCBORBytes(datum, options.plutusData)), + datums.map( + (datum) => + (typeof datum === "bigint" ? undefined : OriginalBytes.get(datum)) ?? + PlutusData.toCBORBytes(datum, options.plutusData) + ), options.ledger ) ) From e0c95401b7b136a768b20c4ccfc21ad9537ff4a2 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 03/10] feat(builder): spend datum hash outputs collectFrom takes a datums field and build takes onMissingDatum. The builder checks the spent datum hash inputs against the datums before coin selection and again at assembly, and the fake witness set counts them. --- .../src/sdk/builders/TransactionBuilder.ts | 26 +++ .../src/sdk/builders/internal/build.ts | 1 + .../src/sdk/builders/internal/state.ts | 1 + .../src/sdk/builders/internal/txBuilder.ts | 209 ++++++++++++++++-- .../src/sdk/builders/operations/Collect.ts | 14 +- .../src/sdk/builders/operations/Operations.ts | 24 ++ 6 files changed, 256 insertions(+), 19 deletions(-) diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 35d5bfad1..e372eb7eb 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -439,6 +439,8 @@ export interface TxBuilderState { readonly selectedUtxos: ReadonlyArray readonly outputs: ReadonlyArray readonly scripts: Map + /** Witness datums, given to collectFrom or fetched for onMissingDatum, by datum hash hex */ + readonly datums: Map readonly totalOutputAssets: CoreAssets.Assets readonly totalInputAssets: CoreAssets.Assets readonly redeemers: Map @@ -534,6 +536,30 @@ export interface BuildOptions { */ readonly onInsufficientChange?: "error" | "burn" + /** + * What the build does when a spent input at a Plutus script address holds a + * datum hash and collectFrom was given no datum for it. The ledger rejects + * the transaction unless that datum is in the witness set. + * + * - "error": the build fails and names each such input. It makes no request. + * - "provider": the build fetches each such datum once with + * `provider.getDatum`, checks that its bytes hash to the datum hash, and + * writes those bytes to the witness set. + * + * @example + * ```typescript + * const signBuilder = await client + * .newTx() + * .collectFrom({ inputs: [scriptUtxo], redeemer }) + * .attachScript({ script }) + * .build({ onMissingDatum: "provider" }) + * ``` + * + * @default "error" + * @since 2.0.0 + */ + readonly onMissingDatum?: "error" | "provider" + /** * Script evaluator for Plutus script execution costs. * diff --git a/packages/evolution/src/sdk/builders/internal/build.ts b/packages/evolution/src/sdk/builders/internal/build.ts index 3e2b13893..c71b0c0b1 100644 --- a/packages/evolution/src/sdk/builders/internal/build.ts +++ b/packages/evolution/src/sdk/builders/internal/build.ts @@ -173,6 +173,7 @@ export const makeBuild = ( yield* TxBuilderImpl.validateVoterRedeemers yield* TxBuilderImpl.validateCertRedeemers yield* TxBuilderImpl.validateProposalRedeemers + yield* TxBuilderImpl.resolveSpendDatums const { transaction, txWithFakeWitnesses } = yield* phaseStateMachine diff --git a/packages/evolution/src/sdk/builders/internal/state.ts b/packages/evolution/src/sdk/builders/internal/state.ts index 1a40b4d4e..7b21e9361 100644 --- a/packages/evolution/src/sdk/builders/internal/state.ts +++ b/packages/evolution/src/sdk/builders/internal/state.ts @@ -24,6 +24,7 @@ export const makeInitialTxBuilderState = (): TxBuilderState => ({ selectedUtxos: [], outputs: [], scripts: new Map(), + datums: new Map(), totalOutputAssets: CoreAssets.zero, totalInputAssets: CoreAssets.zero, redeemers: new Map(), diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index 1590c1e53..8891a0386 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -1,4 +1,5 @@ // Effect-TS imports +import { blake2b } from "@noble/hashes/blake2.js" import { Effect, Ref } from "effect" import type * as Array from "effect/Array" @@ -10,12 +11,14 @@ import * as Bytes from "../../../Bytes.js" import * as CBOR from "../../../CBOR.js" import type * as Certificate from "../../../Certificate.js" import * as CostModel from "../../../CostModel.js" -import type * as PlutusData from "../../../Data.js" +import * as PlutusData from "../../../Data.js" +import type * as DatumHash from "../../../DatumHash.js" import type * as DatumOption from "../../../DatumOption.js" import * as Ed25519Signature from "../../../Ed25519Signature.js" import type * as GovernanceAction from "../../../GovernanceAction.js" import type * as KeyHash from "../../../KeyHash.js" import * as NativeScripts from "../../../NativeScripts.js" +import * as OriginalBytes from "../../../OriginalBytes.js" import type * as PlutusV1 from "../../../PlutusV1.js" import type * as PlutusV2 from "../../../PlutusV2.js" import type * as PlutusV3 from "../../../PlutusV3.js" @@ -44,7 +47,7 @@ import { proposalKeyToIndex, proposalToKey, TransactionBuilderError, - type TxBuilderConfigTag, + TxBuilderConfigTag, type TxBuilderState, TxContext, type UnfrackOptions, @@ -333,6 +336,186 @@ const makeIsNativeScript = return undefined } +// ============================================================================ +// Witness datums +// ============================================================================ + +/** + * The bytes the witness set writes for a datum: the bytes it was decoded + * from, when they were recorded with `OriginalBytes`, else its encoding with + * the Plutus data options. A bare integer keeps no bytes. + * + * @since 2.0.0 + * @category witness-datums + */ +export const witnessDatumBytes = (datum: PlutusData.Data, plutusData: CBOR.CodecOptions): Uint8Array => + (typeof datum === "bigint" ? undefined : OriginalBytes.get(datum)) ?? PlutusData.toCBORBytes(datum, plutusData) + +/** + * The datum hash, as hex, of the bytes the witness set writes for a datum. + * + * @since 2.0.0 + * @category witness-datums + */ +export const witnessDatumHash = (datum: PlutusData.Data, plutusData: CBOR.CodecOptions): string => + Bytes.toHex(blake2b(witnessDatumBytes(datum, plutusData), { dkLen: 32 })) + +type RequiredDatum = { readonly datumHash: DatumHash.DatumHash; readonly input: string } + +// The datum hashes the ledger needs in the witness set, each with the first +// input that holds it: spent inputs with a datum hash whose payment script is +// not a native script (cardano-ledger Alonzo/UTxO.hs getInputDataHashesTxBody). +// A script the builder cannot classify counts as Plutus, as in collectFrom +const requiredDatums = (state: TxBuilderState): Map => { + const isNativeScript = makeIsNativeScript(state) + const required = new Map() + for (const utxo of state.selectedUtxos) { + const datumOption = utxo.datumOption + const credential = utxo.address.paymentCredential + if (datumOption?._tag !== "DatumHash" || credential?._tag !== "ScriptHash") continue + if (isNativeScript(ScriptHash.toHex(credential)) === true) continue + const hash = Bytes.toHex(datumOption.hash) + if (!required.has(hash)) required.set(hash, { datumHash: datumOption, input: CoreUTxO.toOutRefString(utxo) }) + } + return required +} + +// The datum hashes of the outputs and the reference inputs. The ledger accepts +// their datums too (getBabbageSupplementalDataHashes, Babbage/UTxO.hs), but +// collectFrom takes only the datums of spent inputs +const supplementalDatumHashes = (state: TxBuilderState): Set => { + const hashes = new Set() + for (const { datumOption } of [...state.outputs, ...state.referenceInputs]) { + if (datumOption?._tag === "DatumHash") hashes.add(Bytes.toHex(datumOption.hash)) + } + return hashes +} + +const missingDatumsError = (missing: ReadonlyArray, unmatched: ReadonlyArray) => + new TransactionBuilderError({ + message: + `Datum required for ${missing.length} input(s) that hold a datum hash: ` + + missing.map(({ datumHash, input }) => `${input} (datum hash ${Bytes.toHex(datumHash.hash)})`).join(", ") + + '. Pass each datum to collectFrom({ datums }), or build with onMissingDatum: "provider" to fetch it with provider.getDatum' + + (unmatched.length > 0 + ? `. The given datum(s) ${unmatched.join(", ")} hash to none of them: a datum built by hand must be in the layout its hash covers, or carry those bytes through OriginalBytes.record` + : ""), + cause: { + missing: missing.map(({ datumHash, input }) => ({ input, datumHash: Bytes.toHex(datumHash.hash) })), + unmatched + } + }) + +const unneededDatumsError = (unmatched: ReadonlyArray, supplemental: ReadonlySet) => { + const nowhere = unmatched.filter((hash) => !supplemental.has(hash)) + const elsewhere = unmatched.filter((hash) => supplemental.has(hash)) + const parts: Array = [] + if (nowhere.length > 0) { + parts.push( + `Datum(s) ${nowhere.join(", ")} given to collectFrom match no spent input that holds a datum hash at a Plutus script address, ` + + "and the ledger rejects a datum no input needs (NotAllowedSupplementalDatums)." + ) + } + if (elsewhere.length > 0) { + parts.push( + `Datum(s) ${elsewhere.join(", ")} given to collectFrom belong to an output or a reference input; collectFrom takes the datums of spent inputs only.` + ) + } + return new TransactionBuilderError({ message: parts.join(" "), cause: { unmatched } }) +} + +/** + * Match the datums given to collectFrom to the spent inputs, as the ledger + * does (`missingRequiredDatums`): each input that holds a datum hash at a + * Plutus script address needs its datum, and each given datum must belong to + * one of those inputs. A missing datum fails the build, unless + * `onMissingDatum` is `"provider"`: then each one is fetched once with + * `provider.getDatum`, and its bytes must hash to its datum hash. + * + * @since 2.0.0 + * @category witness-datums + */ +export const resolveSpendDatums: Effect.Effect< + void, + TransactionBuilderError, + TxContext | TxBuilderConfigTag | BuildOptionsTag +> = Effect.gen(function* () { + const stateRef = yield* TxContext + const state = yield* Ref.get(stateRef) + const { codecOptions, onMissingDatum = "error" } = yield* BuildOptionsTag + const required = requiredDatums(state) + const unmatched = globalThis.Array.from(state.datums.keys()).filter((hash) => !required.has(hash)) + const missing = globalThis.Array.from(required) + .filter(([hash]) => !state.datums.has(hash)) + .map(([, datum]) => datum) + if (missing.length > 0 && onMissingDatum === "error") { + return yield* Effect.fail(missingDatumsError(missing, unmatched)) + } + if (unmatched.length > 0) return yield* Effect.fail(unneededDatumsError(unmatched, supplementalDatumHashes(state))) + if (missing.length === 0) return + + const { provider } = yield* TxBuilderConfigTag + if (provider === undefined) { + return yield* Effect.fail( + new TransactionBuilderError({ message: 'onMissingDatum: "provider" needs a provider in the builder config' }) + ) + } + const { plutusData } = CBOR.toTxCodecOptions(codecOptions ?? CBOR.TX_DEFAULT_OPTIONS) + const datums = new Map(state.datums) + for (const { datumHash, input } of missing) { + const expected = Bytes.toHex(datumHash.hash) + const datum = yield* provider.effect.getDatum(datumHash).pipe( + Effect.mapError( + (error) => + new TransactionBuilderError({ + message: `provider.getDatum failed for datum hash ${expected} of input ${input}: ${error.message}`, + cause: error + }) + ) + ) + const actual = witnessDatumHash(datum, plutusData) + if (actual !== expected) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + `The datum the provider returned for input ${input} hashes to ${actual}, not to its datum hash ${expected}. ` + + "The provider must return the datum with the bytes it was decoded from.", + cause: { input, datumHash: expected, actual } + }) + ) + } + yield* Effect.logDebug(`[WitnessDatums] Fetched datum ${expected} for input ${input}`) + datums.set(expected, datum) + } + yield* Ref.update(stateRef, (current) => ({ ...current, datums })) +}) + +// The witness datums, once per datum hash. Fails when an input added after +// resolveSpendDatums, by coin selection, needs a datum the build does not +// have, or when the bytes a datum would be written with no longer hash to its +// datum hash +const witnessDatums = ( + state: TxBuilderState, + plutusData: CBOR.CodecOptions +): Effect.Effect, TransactionBuilderError> => + Effect.gen(function* () { + const missing = globalThis.Array.from(requiredDatums(state)) + .filter(([hash]) => !state.datums.has(hash)) + .map(([, datum]) => datum) + if (missing.length > 0) return yield* Effect.fail(missingDatumsError(missing, [])) + for (const [hash, datum] of state.datums) { + if (witnessDatumHash(datum, plutusData) !== hash) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: `The witness datum for datum hash ${hash} would be written with bytes that hash to another hash`, + cause: { datumHash: hash } + }) + ) + } + } + return globalThis.Array.from(state.datums.values()) + }) + /** * Validate redeemer requirements for governance voters, distinguishing native-script * voters (satisfied by vkey witnesses) from Plutus-script voters (which require a redeemer). @@ -814,19 +997,12 @@ export const assembleTransaction = ( redeemers.push(redeemer) } - // Extract plutus data (datums) from selected UTxOs - // NOTE: Only datum hashes need to be resolved in the witness set's plutusData field. - // Inline datums (Babbage era feature) are already embedded in the UTxO output - // and should NOT be included in the witness set - doing so causes "extraneous datums" error. - const plutusDataArray: Array = [] - for (const utxo of state.selectedUtxos) { - if (utxo.datumOption?._tag === "DatumHash") { - // For datum hash, we need to resolve and include the actual datum - // TODO: Implement datum resolution from provider or state - yield* Effect.logDebug(`[Assembly] Found datum hash UTxO (resolution not yet implemented)`) - } - // Inline datums (InlineDatum) are NOT added to plutusData - they're already in the UTxO - } + // The datums of the spent datum hash inputs, as collectFrom took them or + // resolveSpendDatums fetched them. An inline datum stays in its output + const plutusDataArray = yield* witnessDatums( + state, + CBOR.toTxCodecOptions(codecOptions ?? CBOR.TX_DEFAULT_OPTIONS).plutusData + ) // Compute scriptDataHash if there are Plutus scripts (redeemers present) let scriptDataHash: ReturnType | undefined @@ -1292,7 +1468,8 @@ export const buildFakeWitnessSet = ( nativeScripts, bootstrapWitnesses: [], plutusV1Scripts, - plutusData: [], + // The fee covers the witness datums + plutusData: globalThis.Array.from(state.datums.values()), redeemers: fakeRedeemers.length > 0 ? Redeemers.makeRedeemerMap(fakeRedeemers) : undefined, plutusV2Scripts, plutusV3Scripts diff --git a/packages/evolution/src/sdk/builders/operations/Collect.ts b/packages/evolution/src/sdk/builders/operations/Collect.ts index 43dff1c87..20d6c9b52 100644 --- a/packages/evolution/src/sdk/builders/operations/Collect.ts +++ b/packages/evolution/src/sdk/builders/operations/Collect.ts @@ -8,11 +8,12 @@ import { Effect, Ref } from "effect" import * as CoreAssets from "../../../Assets.js" +import * as CBOR from "../../../CBOR.js" import * as ScriptHash from "../../../ScriptHash.js" import * as UTxO from "../../../UTxO.js" -import { filterScriptUtxos } from "../internal/txBuilder.js" +import { filterScriptUtxos, witnessDatumHash } from "../internal/txBuilder.js" import * as RedeemerBuilder from "../RedeemerBuilder.js" -import { TransactionBuilderError, TxContext } from "../TransactionBuilder.js" +import { BuildOptionsTag, TransactionBuilderError, TxContext } from "../TransactionBuilder.js" import type { CollectFromParams } from "./Operations.js" /** @@ -37,10 +38,12 @@ import type { CollectFromParams } from "./Operations.js" */ export const createCollectFromProgram = ( params: CollectFromParams -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ctx = yield* TxContext const state = yield* Ref.get(ctx) + const { codecOptions } = yield* BuildOptionsTag + const { plutusData } = CBOR.toTxCodecOptions(codecOptions ?? CBOR.TX_DEFAULT_OPTIONS) // 1. Validate inputs exist if (params.inputs.length === 0) { @@ -92,6 +95,10 @@ export const createCollectFromProgram = ( // 5. Add UTxOs to selected inputs and track redeemers and input assets const inputAssets = UTxO.totalAssets(params.inputs) + // 6. Key each given datum by the hash of the bytes the witness set writes: + // the bytes it was decoded from, or else the build's plutusData encoding + const givenDatums = (params.datums ?? []).map((datum) => [witnessDatumHash(datum, plutusData), datum] as const) + yield* Ref.update(ctx, (state) => { let newRedeemers = state.redeemers let newDeferredRedeemers = state.deferredRedeemers @@ -133,6 +140,7 @@ export const createCollectFromProgram = ( selectedUtxos: [...state.selectedUtxos, ...params.inputs], redeemers: newRedeemers, deferredRedeemers: newDeferredRedeemers, + datums: givenDatums.length > 0 ? new Map([...state.datums, ...givenDatums]) : state.datums, totalInputAssets: CoreAssets.merge(state.totalInputAssets, inputAssets) } }) diff --git a/packages/evolution/src/sdk/builders/operations/Operations.ts b/packages/evolution/src/sdk/builders/operations/Operations.ts index e6a7f91fd..563990eec 100644 --- a/packages/evolution/src/sdk/builders/operations/Operations.ts +++ b/packages/evolution/src/sdk/builders/operations/Operations.ts @@ -2,6 +2,7 @@ import type * as CoreAddress from "../../../Address.js" import type * as Anchor from "../../../Anchor.js" import type * as CoreAssets from "../../../Assets.js" import type * as Credential from "../../../Credential.js" +import type * as Data from "../../../Data.js" import type * as CoreDatumOption from "../../../DatumOption.js" import type * as DRep from "../../../DRep.js" import type * as EpochNo from "../../../EpochNo.js" @@ -68,6 +69,29 @@ export interface CollectFromParams { readonly redeemer?: RedeemerBuilder.RedeemerArg /** Optional label for debugging script failures - identifies this operation in error messages */ readonly label?: string + /** + * The datum of each input that holds a datum hash at a Plutus script + * address. The witness set must carry it with the bytes its hash covers. + * + * A datum from `provider.getDatum` keeps the bytes it was fetched with, + * whatever the build's codec options say. Any other datum is written with + * the build's `plutusData` options, so a datum built by hand must be in the + * layout its hash was computed with, or carry those bytes through + * `OriginalBytes.record`. A bare integer keeps no bytes and is written in + * its shortest form. + * + * The build fails when a given datum belongs to no spent input, and when a + * spent input's datum is missing, unless the build uses + * `onMissingDatum: "provider"`. + * + * @example + * ```typescript + * if (utxo.datumOption?._tag !== "DatumHash") throw new Error("expected a datum hash") + * const datum = await client.getDatum(utxo.datumOption) + * client.newTx().collectFrom({ inputs: [utxo], redeemer, datums: [datum] }) + * ``` + */ + readonly datums?: ReadonlyArray } export interface ReadFromParams { From 601fd775bcf798b72a9c11c3a963085639a2c9d1 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 04/10] fix(builder): keep only the transaction's datums in assemble --- packages/evolution/src/sdk/builders/SignBuilderImpl.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts index 7dc053cd6..cc9bfa810 100644 --- a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts @@ -247,7 +247,8 @@ export const makeSignBuilder = (params: { plutusV1Scripts: [...(acc.plutusV1Scripts ?? []), ...(ws.plutusV1Scripts ?? [])], plutusV2Scripts: [...(acc.plutusV2Scripts ?? []), ...(ws.plutusV2Scripts ?? [])], plutusV3Scripts: [...(acc.plutusV3Scripts ?? []), ...(ws.plutusV3Scripts ?? [])], - plutusData: [...(acc.plutusData ?? []), ...(ws.plutusData ?? [])], + // The body's script data hash covers the transaction's own datums + plutusData: acc.plutusData, redeemers: acc.redeemers }), // Start from transaction's witness set (NOT empty) to preserve attached scripts From e86131db92a04acc1e6c0504f2cb5d18a9b4b137 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 05/10] test: cover datum hash spends and fetched datum bytes --- .../test/TxBuilder.SpendDatumHash.test.ts | 434 ++++++++++++++++++ .../test/provider/Blockfrost.test.ts | 19 + .../test/provider/KoiosLovelace.test.ts | 20 + .../evolution/test/provider/Kupmios.test.ts | 19 + .../evolution/test/provider/Maestro.test.ts | 19 + .../evolution/test/provider/conformance.ts | 11 +- .../test/utils/codec-build-helpers.ts | 34 +- 7 files changed, 549 insertions(+), 7 deletions(-) create mode 100644 packages/evolution/test/TxBuilder.SpendDatumHash.test.ts diff --git a/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts b/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts new file mode 100644 index 000000000..55fcc546d --- /dev/null +++ b/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts @@ -0,0 +1,434 @@ +import { blake2b } from "@noble/hashes/blake2.js" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" + +import * as CoreAddress from "../src/Address.js" +import * as CoreAssets from "../src/Assets.js" +import * as Bytes from "../src/Bytes.js" +import * as CBOR from "../src/CBOR.js" +import * as CostModel from "../src/CostModel.js" +import * as Data from "../src/Data.js" +import * as DatumHash from "../src/DatumHash.js" +import * as InlineDatum from "../src/InlineDatum.js" +import * as NativeScripts from "../src/NativeScripts.js" +import * as OriginalBytes from "../src/OriginalBytes.js" +import * as ScriptHash from "../src/ScriptHash.js" +import { makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" +import { preprod } from "../src/sdk/client/index.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as CoreUTxO from "../src/UTxO.js" +import { + fetchedDatum as fetched, + FULL_PP, + makeStubEvaluator, + makeStubProvider, + spendScript, + wallet, + walletAddress, + walletUtxos +} from "./utils/codec-build-helpers.js" + +const hex = Bytes.toHex +const hashHex = (bytes: Uint8Array) => hex(blake2b(bytes, { dkLen: 32 })) +const datumHashOf = (datumHex: string) => + new DatumHash.DatumHash({ hash: blake2b(Bytes.fromHex(datumHex), { dkLen: 32 }) }) + +const scriptAddress = new CoreAddress.Address({ networkId: 0, paymentCredential: ScriptHash.fromScript(spendScript) }) + +const lockedUtxo = (datumHex: string, index = 0n, address = scriptAddress) => + new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex("c".repeat(64)), + index, + address, + assets: CoreAssets.fromLovelace(10_000_000n), + datumOption: datumHashOf(datumHex) + }) + +const costModels = new CostModel.CostModels({ + PlutusV1: new CostModel.CostModel({ costs: [] }), + PlutusV2: new CostModel.CostModel({ costs: [] }), + PlutusV3: new CostModel.CostModel({ costs: Object.values(FULL_PP.costModels.PlutusV3).map(BigInt) }) +}) + +// Byte spans of the entries of the CBOR map or array at `start` +const itemSpans = (bytes: Uint8Array, start: number): Array => { + const info = bytes[start]! & 0x1f + const spans: Array = [] + if (info === 31) { + let offset = start + 1 + while (bytes[offset] !== 0xff) { + const { newOffset } = CBOR.decodeItemWithOffset(bytes, offset) + spans.push(bytes.subarray(offset, newOffset)) + offset = newOffset + } + return spans + } + const extra = info < 24 ? 0 : 2 ** (info - 24) + let length = info < 24 ? info : 0 + for (let i = 1; i <= extra; i++) length = length * 256 + bytes[start + i]! + const count = bytes[start]! >> 5 === 5 ? 2 * length : length + let offset = start + 1 + extra + for (let i = 0; i < count; i++) { + const { newOffset } = CBOR.decodeItemWithOffset(bytes, offset) + spans.push(bytes.subarray(offset, newOffset)) + offset = newOffset + } + return spans +} + +// The datum set and redeemers of a written transaction, and the script data +// hash the ledger computes over them +const writtenWitnessData = (txBytes: Uint8Array) => { + const [, witnessSetBytes] = itemSpans(txBytes, 0) + const entries = itemSpans(witnessSetBytes!, 0) + const field = (key: number) => { + const i = entries.findIndex((k, j) => j % 2 === 0 && k.length === 1 && k[0] === key) + return i < 0 ? undefined : entries[i + 1] + } + const datums = field(4) + const redeemers = field(5)! + const parts = [redeemers, datums ?? new Uint8Array(), CostModel.languageViewsEncoding(costModels)] + const payload = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)) + let offset = 0 + for (const p of parts) { + payload.set(p, offset) + offset += p.length + } + return { datums: datums === undefined ? undefined : hex(datums), scriptDataHash: hashHex(payload) } +} + +const recordedHex = (datum: Data.Data) => { + const bytes = typeof datum === "bigint" ? undefined : OriginalBytes.get(datum) + return bytes === undefined ? undefined : hex(bytes) +} + +type BuildArgs = { + readonly inputs: ReadonlyArray + readonly datums?: ReadonlyArray + readonly codecOptions?: CBOR.TxCodecOptions | CBOR.CodecOptions + readonly onMissingDatum?: "error" | "provider" + readonly served?: Readonly> + readonly referenceInputs?: ReadonlyArray +} + +const build = ({ codecOptions, datums, inputs, onMissingDatum, referenceInputs, served }: BuildArgs) => { + const { datumRequests, provider, submitted } = makeStubProvider(served) + const { evaluated, evaluator } = makeStubEvaluator() + const builder = makeTxBuilder({ chain: preprod, wallet, provider }) + const withReferences = referenceInputs === undefined ? builder : builder.readFrom({ referenceInputs }) + const built = withReferences + .collectFrom({ inputs, redeemer: Data.constr(0n, []), datums }) + .attachScript({ script: spendScript }) + .payToAddress({ address: walletAddress, assets: CoreAssets.fromLovelace(2_000_000n) }) + .build({ + changeAddress: walletAddress, + availableUtxos: walletUtxos, + fullProtocolParameters: FULL_PP, + evaluator, + codecOptions, + onMissingDatum + }) + return { built, datumRequests, evaluated, submitted } +} + +// Datums as other tools write them; the builder writes none of them this way +const layouts: ReadonlyArray = [ + ["node layout", "d8799fa101029f03ffff"], + ["definite", "d87982a101028103"], + ["indefinite map", "d8799fbf0102ff9f03ffff"], + ["chunked bytes over 64", `d8799f5f5840${"ab".repeat(64)}46${"ab".repeat(6)}ffff`], + ["bytes chunked 1 then 64", `5f41225840${"11".repeat(64)}ff`], + ["non-minimal lengths", "d87998011801"], + ["bare definite list", "820102"], + ["bare indefinite map", "bf0102ff"], + ["bare bytes", "43010203"], + ["bare integer", "182a"] +] + +const optionSets: ReadonlyArray = [ + ["default", undefined], + ["canonical", CBOR.TX_CANONICAL_OPTIONS], + ["hw-cli", CBOR.TX_HW_CLI_OPTIONS] +] + +// The datum set is written byte for byte in the built, fake-witness, +// evaluated and submitted transactions, the script data hash covers it, and +// the fee covers it +const expectWrittenEverywhere = async (args: BuildArgs, datumHex: string) => { + const datumSet = `d9010281${datumHex}` + const { built, evaluated, submitted } = build(args) + const result = await built + const tx = Transaction.toCBORBytes(await result.toTransaction()) + const fake = Transaction.toCBORBytes(await result.toTransactionWithFakeWitnesses()) + await (await result.sign()).submit() + await (await result.assemble([await result.partialSign()])).submit() + + expect(evaluated.length).toBeGreaterThan(0) + for (const bytes of [tx, fake, ...evaluated, ...submitted]) { + expect(writtenWitnessData(bytes).datums).toBe(datumSet) + } + const { body } = await result.toTransaction() + expect(hex(body.scriptDataHash!.hash)).toBe(writtenWitnessData(tx).scriptDataHash) + expect(body.fee >= BigInt(FULL_PP.minFeeA * fake.length + FULL_PP.minFeeB)).toBe(true) + expect(submitted).toHaveLength(2) +} + +describe("spending a datum hash output with collectFrom datums", () => { + it.each(layouts)("a fetched %s datum is written with its own bytes everywhere", async (_, datumHex) => { + await expectWrittenEverywhere({ inputs: [lockedUtxo(datumHex)], datums: [fetched(datumHex)] }, datumHex) + }) + + it.each(optionSets.slice(1))("a fetched datum keeps its own bytes under %s options", async (_, codecOptions) => { + const datumHex = "d8799fbf0102ff9f03ffff" + await expectWrittenEverywhere( + { inputs: [lockedUtxo(datumHex)], datums: [fetched(datumHex)], codecOptions }, + datumHex + ) + }) + + it("a new datum is written with the build's plutusData options", async () => { + const datum = Data.constr(0n, [Data.map([[1n, 2n]]), Data.list([3n])]) + for (const [, codecOptions] of optionSets.slice(0, 2)) { + const datumHex = Data.toCBORHex(datum, CBOR.toTxCodecOptions(codecOptions ?? CBOR.TX_DEFAULT_OPTIONS).plutusData) + const result = await build({ inputs: [lockedUtxo(datumHex)], datums: [datum], codecOptions }).built + const tx = Transaction.toCBORBytes(await result.toTransaction()) + expect(writtenWitnessData(tx).datums).toBe(`d9010281${datumHex}`) + } + }) + + it("two inputs under one datum hash take the datum once", async () => { + const datumHex = "d87982a101028103" + const result = await build({ + inputs: [lockedUtxo(datumHex, 0n), lockedUtxo(datumHex, 1n)], + datums: [fetched(datumHex)] + }).built + const tx = Transaction.toCBORBytes(await result.toTransaction()) + expect(writtenWitnessData(tx).datums).toBe(`d9010281${datumHex}`) + }) + + it("fails before selection when an input's datum is not given, and names both ways to give it", async () => { + const utxo = lockedUtxo("d87982a101028103") + const { built, datumRequests } = build({ inputs: [utxo] }) + const message = await built.then( + () => "built", + (error: unknown) => String(error) + ) + expect(message).toContain(`Datum required for 1 input(s) that hold a datum hash: ${CoreUTxO.toOutRefString(utxo)}`) + expect(message).toContain("collectFrom({ datums })") + expect(message).toContain('onMissingDatum: "provider"') + expect(datumRequests).toHaveLength(0) + }) + + it("fails when a hand-built datum has other bytes than the hash covers", async () => { + // The definite layout is locked; the build writes the node layout + const datum = Data.constr(0n, [Data.map([[1n, 2n]]), Data.list([3n])]) + await expect(build({ inputs: [lockedUtxo("d87982a101028103")], datums: [datum] }).built).rejects.toThrow( + "hash to none of them: a datum built by hand must be in the layout its hash covers" + ) + }) + + it("a bare integer datum with non-minimal bytes cannot keep them, and the build says so", async () => { + // 5 written as 1805: a bigint has no object to record bytes against + await expect(build({ inputs: [lockedUtxo("1805")], datums: [fetched("1805")] }).built).rejects.toThrow( + "hash to none of them" + ) + }) + + it("fails when a datum is given for a key-address input", async () => { + const { provider } = makeStubProvider() + await expect( + makeTxBuilder({ chain: preprod, wallet, provider }) + .collectFrom({ inputs: [lockedUtxo("d87980", 0n, walletAddress)], datums: [fetched("d87980")] }) + .payToAddress({ address: walletAddress, assets: CoreAssets.fromLovelace(2_000_000n) }) + .build({ changeAddress: walletAddress, availableUtxos: walletUtxos, fullProtocolParameters: FULL_PP }) + ).rejects.toThrow("NotAllowedSupplementalDatums") + }) + + it("fails with a builder limit, not a ledger rule, for the datum of a reference input", async () => { + const spentHex = "d87982a101028103" + const referenceHex = "d87981182a" + await expect( + build({ + inputs: [lockedUtxo(spentHex)], + datums: [fetched(spentHex), fetched(referenceHex)], + referenceInputs: [lockedUtxo(referenceHex, 7n, walletAddress)] + }).built + ).rejects.toThrow( + `Datum(s) ${hex(datumHashOf(referenceHex).hash)} given to collectFrom belong to an output or a reference input` + ) + }) + + it("gives no datum to a native script input that holds a datum hash, and refuses one", async () => { + const nativeScript = NativeScripts.makeScriptPubKey(new Uint8Array(28).fill(0xaa)) + const nativeAddress = new CoreAddress.Address({ + networkId: 0, + paymentCredential: ScriptHash.fromScript(nativeScript) + }) + const utxo = lockedUtxo("d87980", 0n, nativeAddress) + const spend = (datums?: ReadonlyArray) => { + const { datumRequests, provider } = makeStubProvider() + const built = makeTxBuilder({ chain: preprod, wallet, provider }) + .attachScript({ script: nativeScript }) + .collectFrom({ inputs: [utxo], datums }) + .payToAddress({ address: walletAddress, assets: CoreAssets.fromLovelace(2_000_000n) }) + .build({ + changeAddress: walletAddress, + availableUtxos: walletUtxos, + fullProtocolParameters: FULL_PP, + onMissingDatum: "provider" + }) + return { built, datumRequests } + } + const { built, datumRequests } = spend() + expect((await (await built).toTransaction()).witnessSet.plutusData ?? []).toHaveLength(0) + expect(datumRequests).toHaveLength(0) + await expect(spend([fetched("d87980")]).built).rejects.toThrow("NotAllowedSupplementalDatums") + }) + + it("assemble writes the datum once when a given witness set carries it too", async () => { + const datumHex = "d87982a101028103" + const { built, submitted } = build({ inputs: [lockedUtxo(datumHex)], datums: [fetched(datumHex)] }) + const result = await built + await (await result.assemble([await result.partialSign(), await result.getWitnessSet()])).submit() + const written = writtenWitnessData(submitted[0]!) + expect(written.datums).toBe(`d9010281${datumHex}`) + expect(hex((await result.toTransaction()).body.scriptDataHash!.hash)).toBe(written.scriptDataHash) + }) + + it("fails at assembly when coin selection adds a datum-hash script input", async () => { + const { provider } = makeStubProvider() + const { evaluator } = makeStubEvaluator() + const inline = new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex("d".repeat(64)), + index: 0n, + address: scriptAddress, + assets: CoreAssets.fromLovelace(2_000_000n), + datumOption: new InlineDatum.InlineDatum({ data: Data.int(1n) }) + }) + const hashed = new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex("d".repeat(64)), + index: 1n, + address: scriptAddress, + assets: CoreAssets.fromLovelace(900_000_000_000n), + datumOption: datumHashOf("01") + }) + const run = makeTxBuilder({ chain: preprod, wallet, provider }) + .collectFrom({ inputs: [inline], redeemer: Data.constr(0n, []) }) + .attachScript({ script: spendScript }) + .payToAddress({ address: walletAddress, assets: CoreAssets.fromLovelace(500_000_000_000n) }) + .build({ + changeAddress: walletAddress, + availableUtxos: [hashed, ...walletUtxos], + fullProtocolParameters: FULL_PP, + evaluator + }) + await expect(run).rejects.toThrow(/Datum required for 1 input\(s\)/) + }) +}) + +describe("onMissingDatum: provider", () => { + for (const [optionsName, codecOptions] of optionSets) { + it.each(layouts)(`fetches a %s datum and writes its own bytes under ${optionsName} options`, async (_, datumHex) => { + const datumHash = hex(datumHashOf(datumHex).hash) + const args: BuildArgs = { + inputs: [lockedUtxo(datumHex, 0n), lockedUtxo(datumHex, 1n)], + onMissingDatum: "provider", + served: { [datumHash]: datumHex }, + codecOptions + } + await expectWrittenEverywhere(args, datumHex) + // Two inputs share the hash: one request + const { built, datumRequests } = build(args) + await built + expect(datumRequests).toStrictEqual([datumHash]) + }) + } + + it("fetches only the datums collectFrom was not given", async () => { + const givenHex = "d87982a101028103" + const fetchedHex = "d8799fbf0102ff9f03ffff" + const fetchedHash = hex(datumHashOf(fetchedHex).hash) + const { built, datumRequests } = build({ + inputs: [lockedUtxo(givenHex, 0n), lockedUtxo(fetchedHex, 1n)], + datums: [fetched(givenHex)], + onMissingDatum: "provider", + served: { [fetchedHash]: fetchedHex } + }) + const written = (await (await built).toTransaction()).witnessSet.plutusData ?? [] + expect(datumRequests).toStrictEqual([fetchedHash]) + expect(written.map(recordedHex).sort()).toStrictEqual([givenHex, fetchedHex].sort()) + }) + + it("fetches nothing for a key-address input that holds a datum hash", async () => { + const { datumRequests, provider } = makeStubProvider() + const sign = await makeTxBuilder({ chain: preprod, wallet, provider }) + .collectFrom({ inputs: [lockedUtxo("05", 0n, walletAddress)] }) + .payToAddress({ address: walletAddress, assets: CoreAssets.fromLovelace(2_000_000n) }) + .build({ + changeAddress: walletAddress, + availableUtxos: walletUtxos, + fullProtocolParameters: FULL_PP, + onMissingDatum: "provider" + }) + expect(datumRequests).toHaveLength(0) + expect((await sign.toTransaction()).witnessSet.plutusData ?? []).toHaveLength(0) + }) + + it("fails when the provider has no datum", async () => { + await expect( + build({ inputs: [lockedUtxo("d87982a101028103")], onMissingDatum: "provider" }).built + ).rejects.toThrow("provider.getDatum failed for datum hash") + }) + + it("fails when the fetched datum does not hash to the datum hash", async () => { + const datumHash = hex(datumHashOf("1805").hash) + await expect( + build({ inputs: [lockedUtxo("1805")], onMissingDatum: "provider", served: { [datumHash]: "1805" } }).built + ).rejects.toThrow(`not to its datum hash ${datumHash}`) + }) + + it("fails when the builder has no provider", async () => { + const { evaluator } = makeStubEvaluator() + await expect( + makeTxBuilder({ chain: preprod, wallet }) + .collectFrom({ inputs: [lockedUtxo("d87982a101028103")], redeemer: Data.constr(0n, []) }) + .attachScript({ script: spendScript }) + .build({ + changeAddress: walletAddress, + availableUtxos: walletUtxos, + fullProtocolParameters: FULL_PP, + evaluator, + onMissingDatum: "provider" + }) + ).rejects.toThrow('onMissingDatum: "provider" needs a provider in the builder config') + }) +}) + +describe("witness set datums", () => { + it("a witness set built from another keeps each fetched datum's bytes", () => { + const datumHexes = layouts.map(([, h]) => h) + const ws = new TransactionWitnessSet.TransactionWitnessSet({ plutusData: datumHexes.map(fetched) }) + const copy = new TransactionWitnessSet.TransactionWitnessSet({ ...ws, vkeyWitnesses: [] }) + for (const set of [ws, copy]) { + const [, datums] = itemSpans(TransactionWitnessSet.toCBORBytes(set), 0) + expect(itemSpans(datums!, 3).map(hex)).toStrictEqual(datumHexes) + } + }) + + it("keeps each datum as the same object, and its Encoded form", () => { + const map = new Map([[1n, 2n]]) + const list: ReadonlyArray = [1n] + OriginalBytes.record(map, Bytes.fromHex("bf0102ff")) + const ws = new TransactionWitnessSet.TransactionWitnessSet({ plutusData: [map, list] }) + const again = new TransactionWitnessSet.TransactionWitnessSet({ ...ws }) + expect(again.plutusData![0]).toBe(map) + expect(again.plutusData![1]).toBe(list) + expect(Schema.encodeSync(TransactionWitnessSet.TransactionWitnessSet)(ws).plutusData).toEqual([ + [["1", "2"]], + ["1"] + ]) + expect(TransactionWitnessSet.toCBORHex(again)).toContain("d9010282bf0102ff") + }) +}) diff --git a/packages/evolution/test/provider/Blockfrost.test.ts b/packages/evolution/test/provider/Blockfrost.test.ts index a48f1b9bb..d1c30ed87 100644 --- a/packages/evolution/test/provider/Blockfrost.test.ts +++ b/packages/evolution/test/provider/Blockfrost.test.ts @@ -8,9 +8,11 @@ import { afterEach, describe, expect, it, vi } from "vitest" import * as Address from "../../src/Address.js" import * as Assets from "../../src/Assets.js" +import * as Bytes from "../../src/Bytes.js" import * as Data from "../../src/Data.js" import * as DatumHash from "../../src/DatumHash.js" import * as InlineDatum from "../../src/InlineDatum.js" +import * as OriginalBytes from "../../src/OriginalBytes.js" import * as PlutusV2 from "../../src/PlutusV2.js" import * as PoolKeyHash from "../../src/PoolKeyHash.js" import * as RewardAddress from "../../src/RewardAddress.js" @@ -46,6 +48,14 @@ const ADDRESS = "addr1qxqs59lphg8g6qndelq8xwqn60ag3aeyfcp33c2kdp46a09re5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qsgy6pz" const TOKEN_UNIT = "b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a76e7574636f696e" const DATUM_HASH = "9e478573ab81ea7a8e31891ce0648b81229f408d596a3483e6f4f9b92d3cf710" + +// A datum in a layout the default Plutus data options do not write: the +// constructor's map is indefinite +const FETCHED_LAYOUT = "d8799fbf0102ff9f03ffff" +const recordedHex = (datum: Data.Data) => { + const bytes = typeof datum === "bigint" ? undefined : OriginalBytes.get(datum) + return bytes === undefined ? undefined : Bytes.toHex(bytes) +} const SCRIPT_HASH = "13a3efd825703a352a8f71f4e2758d08c28c564e8dfcce9f77776ad1" const SCRIPT_CBOR = "4e4d01000033222220051200120011" const TX_HASH = "768c63e27a1c816a83dc7b07e78af673b2400de8849ea7e7b734ae1333d100d2" @@ -419,6 +429,15 @@ describe("Blockfrost other endpoints", () => { expect(datum).toEqual(Data.fromCBORHex("19a6aa")) }) + it("getDatum keeps the bytes it was fetched with", async () => { + stubRoutes({ [`/scripts/datum/${DATUM_HASH}/cbor`]: `{"cbor":"${FETCHED_LAYOUT}"}` }) + + const datum = await Effect.runPromise(BlockfrostEffect.getDatum(BASE, PROJECT_ID)(DatumHash.fromHex(DATUM_HASH))) + + expect(recordedHex(datum)).toBe(FETCHED_LAYOUT) + expect(Data.toCBORHex(datum)).not.toBe(FETCHED_LAYOUT) + }) + it("awaitTx succeeds once the transaction is found", async () => { stubRoutes({ // /txs/{hash}, trimmed from the OpenAPI example diff --git a/packages/evolution/test/provider/KoiosLovelace.test.ts b/packages/evolution/test/provider/KoiosLovelace.test.ts index 58d85c7ea..5ae192b83 100644 --- a/packages/evolution/test/provider/KoiosLovelace.test.ts +++ b/packages/evolution/test/provider/KoiosLovelace.test.ts @@ -11,8 +11,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" import * as CoreAddress from "../../src/Address.js" import * as CoreAssets from "../../src/Assets.js" +import * as Bytes from "../../src/Bytes.js" import * as PlutusData from "../../src/Data.js" import * as DatumHash from "../../src/DatumHash.js" +import * as OriginalBytes from "../../src/OriginalBytes.js" import * as RewardAddress from "../../src/RewardAddress.js" import * as ScriptHash from "../../src/ScriptHash.js" import * as KoiosEffect from "../../src/sdk/provider/internal/KoiosEffect.js" @@ -69,6 +71,14 @@ const DATUM_INFO = `[{ "bytes": "d8799fd8799f1a001a8ea5ff1b0000018ea6888e58ff" }]` +// A datum in a layout the default Plutus data options do not write: the +// constructor's map is indefinite +const FETCHED_LAYOUT = "d8799fbf0102ff9f03ffff" +const recordedHex = (datum: PlutusData.Data) => { + const bytes = typeof datum === "bigint" ? undefined : OriginalBytes.get(datum) + return bytes === undefined ? undefined : Bytes.toHex(bytes) +} + /** Ogmios `evaluateTransaction` reply that Koios relays from `/ogmios` */ const evaluation = (cpu: string) => { const redeemers = [0, 1, 2, 3].map( @@ -222,6 +232,16 @@ describe("Koios paths without lovelace fields", () => { expect(PlutusData.toCBORHex(datum)).toBe(expected.bytes) }) + it("getDatum keeps the bytes it was fetched with", async () => { + stubKoios({ datum_info: DATUM_INFO.replace(/"bytes": "[0-9a-f]+"/, `"bytes": "${FETCHED_LAYOUT}"`) }) + const [expected] = JSON.parse(DATUM_INFO) + + const datum = await Effect.runPromise(KoiosEffect.getDatum(BASE_URL)(DatumHash.fromHex(expected.datum_hash))) + + expect(recordedHex(datum)).toBe(FETCHED_LAYOUT) + expect(PlutusData.toCBORHex(datum)).not.toBe(FETCHED_LAYOUT) + }) + it("submitTx", async () => { const txHash = "23f94840ca94f7bb0a5a2b28e5b6a77e61d0414c7427e03d6c4d57b13d5e49b4" stubKoios({ submittx: JSON.stringify(txHash) }) diff --git a/packages/evolution/test/provider/Kupmios.test.ts b/packages/evolution/test/provider/Kupmios.test.ts index 357aadb71..ea62eb2ef 100644 --- a/packages/evolution/test/provider/Kupmios.test.ts +++ b/packages/evolution/test/provider/Kupmios.test.ts @@ -11,8 +11,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" import * as CoreAddress from "../../src/Address.js" import * as CoreAssets from "../../src/Assets.js" +import * as Bytes from "../../src/Bytes.js" import * as PlutusData from "../../src/Data.js" import * as DatumHash from "../../src/DatumHash.js" +import * as OriginalBytes from "../../src/OriginalBytes.js" import * as PoolKeyHash from "../../src/PoolKeyHash.js" import * as RewardAddress from "../../src/RewardAddress.js" import * as ScriptHash from "../../src/ScriptHash.js" @@ -33,6 +35,14 @@ const ASSET_NAME = "695553443230323430343033313833343534" const DATUM_HASH = "facfe6aa45fa8023a97a3f13afb823f3966313533f6d68821e65d8431b5a4918" const DATUM = "d8799fd8799f1a001a8ea5ff1b0000018ea6888e58ff" +// A datum in a layout the default Plutus data options do not write: the +// constructor's map is indefinite +const FETCHED_LAYOUT = "d8799fbf0102ff9f03ffff" +const recordedHex = (datum: PlutusData.Data) => { + const bytes = typeof datum === "bigint" ? undefined : OriginalBytes.get(datum) + return bytes === undefined ? undefined : Bytes.toHex(bytes) +} + /** Kupo `/matches` body for that UTxO, with the token quantity as given */ const matches = (quantity: string) => `[{ "transaction_index": 0, @@ -201,6 +211,15 @@ describe("Kupmios read paths", () => { expect(PlutusData.toCBORHex(datum)).toBe(DATUM) }) + it("getDatum keeps the bytes it was fetched with", async () => { + stubKupmios({ kupo: { [`/datums/${DATUM_HASH}`]: `{"datum":"${FETCHED_LAYOUT}"}` } }) + + const datum = await Effect.runPromise(KupmiosEffects.getDatumEffect(KUPO_URL)(DatumHash.fromHex(DATUM_HASH))) + + expect(recordedHex(datum)).toBe(FETCHED_LAYOUT) + expect(PlutusData.toCBORHex(datum)).not.toBe(FETCHED_LAYOUT) + }) + it("getDelegation", async () => { stubKupmios({ ogmios: { "queryLedgerState/rewardAccountSummaries": rewardAccountSummaries("52981948456") } }) diff --git a/packages/evolution/test/provider/Maestro.test.ts b/packages/evolution/test/provider/Maestro.test.ts index dfe654792..80a38c08e 100644 --- a/packages/evolution/test/provider/Maestro.test.ts +++ b/packages/evolution/test/provider/Maestro.test.ts @@ -8,8 +8,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" import * as Address from "../../src/Address.js" import * as Assets from "../../src/Assets.js" +import * as Bytes from "../../src/Bytes.js" import * as Data from "../../src/Data.js" import * as DatumHash from "../../src/DatumHash.js" +import * as OriginalBytes from "../../src/OriginalBytes.js" import * as PoolKeyHash from "../../src/PoolKeyHash.js" import * as MaestroEffect from "../../src/sdk/provider/internal/MaestroEffect.js" import * as Transaction from "../../src/Transaction.js" @@ -221,6 +223,14 @@ describe("Maestro evaluateTx", () => { }) }) +// A datum in a layout the default Plutus data options do not write: the +// constructor's map is indefinite +const FETCHED_LAYOUT = "d8799fbf0102ff9f03ffff" +const recordedHex = (datum: Data.Data) => { + const bytes = typeof datum === "bigint" ? undefined : OriginalBytes.get(datum) + return bytes === undefined ? undefined : Bytes.toHex(bytes) +} + describe("Maestro getDatum and awaitTx", () => { it("getDatum decodes the datum bytes", async () => { stubFetch({ [`${BASE_URL}/datums/${PREPROD_DATUM_HASH_HEX}`]: `{"data":{"bytes":"d87980","json":{"constructor":0,"fields":[]}},${lastUpdated}}` }) @@ -230,6 +240,15 @@ describe("Maestro getDatum and awaitTx", () => { expect(datum).toEqual(Data.fromCBORHex("d87980")) }) + it("getDatum keeps the bytes it was fetched with", async () => { + stubFetch({ [`${BASE_URL}/datums/${PREPROD_DATUM_HASH_HEX}`]: `{"data":{"bytes":"${FETCHED_LAYOUT}","json":{}},${lastUpdated}}` }) + + const datum = await Effect.runPromise(MaestroEffect.getDatum(BASE_URL, API_KEY)(DatumHash.fromHex(PREPROD_DATUM_HASH_HEX))) + + expect(recordedHex(datum)).toBe(FETCHED_LAYOUT) + expect(Data.toCBORHex(datum)).not.toBe(FETCHED_LAYOUT) + }) + it("awaitTx returns once the transaction has a block", async () => { stubFetch({ [`${BASE_URL}/transactions/${PREPROD_TX_HASH_HEX}`]: `{"data":{"tx_hash":"${PREPROD_TX_HASH_HEX}","block_hash":"8a5a2b1d9f0e6c3e2d1b0a99f8e7d6c5b4a39281706f5e4d3c2b1a0918273645","block_height":3456789,"fee":"171573"},${lastUpdated}}` diff --git a/packages/evolution/test/provider/conformance.ts b/packages/evolution/test/provider/conformance.ts index ae3116e7f..0dd4dd34c 100644 --- a/packages/evolution/test/provider/conformance.ts +++ b/packages/evolution/test/provider/conformance.ts @@ -1,8 +1,12 @@ +import { blake2b } from "@noble/hashes/blake2.js" import { beforeAll, expect, it } from "vitest" import * as Address from "../../src/Address.js" import * as Assets from "../../src/Assets.js" +import * as Bytes from "../../src/Bytes.js" import * as Credential from "../../src/Credential.js" +import * as DatumHash from "../../src/DatumHash.js" +import * as OriginalBytes from "../../src/OriginalBytes.js" import * as PoolKeyHash from "../../src/PoolKeyHash.js" import { type Provider } from "../../src/sdk/provider/Provider.js" import * as Transaction from "../../src/Transaction.js" @@ -131,8 +135,11 @@ export function registerConformanceTests(factory: () => Provider, options?: Conf }) it("getDatum", async () => { - const datum = await provider.getDatum(preprodDatumHash()) - expect(datum).toBeDefined() + const datumHash = preprodDatumHash() + const datum = await provider.getDatum(datumHash) + // The datum keeps the bytes it was fetched with, and they hash to its datum hash + const bytes = typeof datum === "bigint" ? undefined : OriginalBytes.get(datum) + expect(bytes && Bytes.toHex(blake2b(bytes, { dkLen: 32 }))).toBe(DatumHash.toHex(datumHash)) }) it("awaitTx", { timeout: 200_000 }, async () => { diff --git a/packages/evolution/test/utils/codec-build-helpers.ts b/packages/evolution/test/utils/codec-build-helpers.ts index dd8d3faec..e312d8a91 100644 --- a/packages/evolution/test/utils/codec-build-helpers.ts +++ b/packages/evolution/test/utils/codec-build-helpers.ts @@ -6,7 +6,9 @@ import * as CoreAssets from "../../src/Assets.js" import * as Bytes from "../../src/Bytes.js" import type * as CBOR from "../../src/CBOR.js" import * as Data from "../../src/Data.js" +import * as DatumHash from "../../src/DatumHash.js" import * as InlineDatum from "../../src/InlineDatum.js" +import * as OriginalBytes from "../../src/OriginalBytes.js" import * as PlutusV3 from "../../src/PlutusV3.js" import * as Redeemer from "../../src/Redeemer.js" import * as ScriptHash from "../../src/ScriptHash.js" @@ -14,7 +16,7 @@ import type { SignBuilder } from "../../src/sdk/builders/SignBuilder.js" import { type Evaluator, makeTxBuilder } from "../../src/sdk/builders/TransactionBuilder.js" import { preprod } from "../../src/sdk/client/index.js" import { seedWallet } from "../../src/sdk/client/internal/Wallets.js" -import type { ProtocolParameters, Provider } from "../../src/sdk/provider/Provider.js" +import { type ProtocolParameters, type Provider, ProviderError } from "../../src/sdk/provider/Provider.js" import * as Transaction from "../../src/Transaction.js" import * as TransactionHash from "../../src/TransactionHash.js" import type * as TransactionMetadatum from "../../src/TransactionMetadatum.js" @@ -173,9 +175,24 @@ export const walletUtxos = [ }) ] -/** A provider that records the bytes `submitTx` writes, as a provider does. */ -export const makeStubProvider = () => { +/** + * A datum as the built-in providers return it from `getDatum`: decoded, with + * the bytes it was fetched with. + */ +export const fetchedDatum = (datumHex: string): Data.Data => { + const bytes = Bytes.fromHex(datumHex) + const data = Data.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) +} + +/** + * A provider that records the bytes `submitTx` writes, as a provider does. + * `getDatum` serves the given datums, by datum hash hex, as `fetchedDatum` + * returns them, and records each request. + */ +export const makeStubProvider = (datums: Readonly> = {}) => { const submitted: Array = [] + const datumRequests: Array = [] const notImplemented = (name: string) => () => Effect.die(new Error(`StubProvider.${name}`)) const effect = { getProtocolParameters: () => Effect.succeed(FULL_PP), @@ -184,7 +201,14 @@ export const makeStubProvider = () => { getUtxoByUnit: notImplemented("getUtxoByUnit"), getUtxosByOutRef: notImplemented("getUtxosByOutRef"), getDelegation: notImplemented("getDelegation"), - getDatum: notImplemented("getDatum"), + getDatum: (datumHash: DatumHash.DatumHash) => { + const hash = DatumHash.toHex(datumHash) + datumRequests.push(hash) + const served = datums[hash] + return served === undefined + ? Effect.fail(new ProviderError({ message: `No datum for ${hash}` })) + : Effect.sync(() => fetchedDatum(served)) + }, awaitTx: notImplemented("awaitTx"), evaluateTx: notImplemented("evaluateTx"), submitTx: (tx: Transaction.Transaction) => @@ -195,7 +219,7 @@ export const makeStubProvider = () => { }) } const provider = { effect } as unknown as Provider - return { provider, submitted } + return { provider, submitted, datumRequests } } /** An evaluator that records the bytes it is given, as the UPLC evaluators write them. */ From ece1240dca993698af64e3b746e3dad2421030f9 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 06/10] test(devnet): spend datum hash outputs through the builder --- .../test/TxBuilder.SpendDatumHash.test.ts | 252 ++++++++++++++++++ .../test/TxBuilder.WitnessDatum.test.ts | 77 +----- 2 files changed, 266 insertions(+), 63 deletions(-) create mode 100644 packages/evolution-devnet/test/TxBuilder.SpendDatumHash.test.ts diff --git a/packages/evolution-devnet/test/TxBuilder.SpendDatumHash.test.ts b/packages/evolution-devnet/test/TxBuilder.SpendDatumHash.test.ts new file mode 100644 index 000000000..a07382336 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.SpendDatumHash.test.ts @@ -0,0 +1,252 @@ +/** + * Devnet test: the builder spends datum hash outputs, and the node accepts + * them. Each datum is in a layout the build's plutusData default does not + * write, so it must keep the bytes it was fetched with. + * + * The lock transaction locks two outputs under each datum hash and publishes + * the datums in its witness set, which the ledger allows for an output's datum + * hash, so Kupo can serve them. The SDK cannot publish a datum, so the test + * adds them by hand. One output more is locked under a datum that is never + * published. + * + * For each datum, one output is spent with the datum fetched by client.getDatum + * and given to collectFrom, and the other with build({ onMissingDatum: + * "provider" }) under the canonical options. Before submitting, the aiken and + * scalus evaluators each evaluate the spend. + */ + +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest" +import { createAikenEvaluator } from "@evolution-sdk/aiken-uplc" +import * as Cluster from "@evolution-sdk/devnet/Cluster" +import * as Config from "@evolution-sdk/devnet/Config" +import * as Genesis from "@evolution-sdk/devnet/Genesis" +import { Cardano, Client, preprod } from "@evolution-sdk/evolution" +import * as Address from "@evolution-sdk/evolution/Address" +import * as Bytes from "@evolution-sdk/evolution/Bytes" +import * as CBOR from "@evolution-sdk/evolution/CBOR" +import * as CostModel from "@evolution-sdk/evolution/CostModel" +import * as Data from "@evolution-sdk/evolution/Data" +import * as DatumHash from "@evolution-sdk/evolution/DatumHash" +import * as OriginalBytes from "@evolution-sdk/evolution/OriginalBytes" +import * as PlutusV3 from "@evolution-sdk/evolution/PlutusV3" +import * as Redeemers from "@evolution-sdk/evolution/Redeemers" +import * as ScriptHash from "@evolution-sdk/evolution/ScriptHash" +import * as Transaction from "@evolution-sdk/evolution/Transaction" +import * as TransactionBody from "@evolution-sdk/evolution/TransactionBody" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as TransactionWitnessSet from "@evolution-sdk/evolution/TransactionWitnessSet" +import * as TxOut from "@evolution-sdk/evolution/TxOut" +import { createScalusEvaluator } from "@evolution-sdk/scalus-uplc" +import { blake2b } from "@noble/hashes/blake2.js" + +import plutusJson from "../../evolution/test/spec/plutus.json" + +const alwaysSucceed = plutusJson.validators.find((v) => v.title === "always_succeed.always_succeed.spend") +if (!alwaysSucceed) throw new Error("always_succeed.always_succeed.spend not found in plutus.json") + +// The datums, as other tools write them. The build default writes Constr 0 +// [{1: 2}, [3]] as d8799fa101029f03ffff +const cases: ReadonlyArray<{ readonly name: string; readonly datumHex: string }> = [ + { name: "definite", datumHex: "d87982a101028103" }, + { name: "indefinite map", datumHex: "d8799fbf0102ff9f03ffff" }, + { name: "non-minimal lengths", datumHex: "d87998011801" }, + { name: "bare indefinite map", datumHex: "bf0102ff" }, + { name: "bytes chunked 1 then 64", datumHex: `5f41225840${"11".repeat(64)}ff` } +] + +// A datum no transaction publishes +const UNPUBLISHED_HEX = "d87a80" + +const hashOf = (datumHex: string) => Bytes.toHex(blake2b(Bytes.fromHex(datumHex), { dkLen: 32 })) + +const withBytes = (datumHex: string): Data.Data => { + const bytes = Bytes.fromHex(datumHex) + const data = Data.fromCBORBytes(bytes) + return typeof data === "bigint" ? data : OriginalBytes.record(data, bytes) +} + +describe("Spend datum hash outputs (Devnet Submit)", () => { + let devnetCluster: Cluster.Cluster | undefined + let genesisUtxos: ReadonlyArray = [] + + const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + + const script = new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(alwaysSucceed.compiledCode) }) + const scriptAddress = Address.Address.make({ networkId: 0, paymentCredential: ScriptHash.fromScript(script) }) + const redeemer = Data.constr(0n, []) + + const ogmiosUrl = () => `http://localhost:${devnetCluster!.ports.ogmios}` + const createTestClient = () => + Client.make(Cluster.getChain(devnetCluster!)) + .withKupmios({ kupoUrl: `http://localhost:${devnetCluster!.ports.kupo}`, ogmiosUrl: ogmiosUrl() }) + .withSeed({ mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }) + + const ogmios = async (method: string, params: unknown) => { + const response = await fetch(ogmiosUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method, params, id: null }) + }) + return (await response.json()) as { result?: any; error?: unknown } + } + + // The script outputs locked under a datum hash, in output order + const lockedUnder = async (datumHex: string) => { + const utxos = (await createTestClient().getUtxos(scriptAddress)).filter( + (utxo) => utxo.datumOption?._tag === "DatumHash" && DatumHash.toHex(utxo.datumOption) === hashOf(datumHex) + ) + return [...utxos].sort((a, b) => Number(a.index - b.index)) + } + + beforeAll(async () => { + const tempClient = Client.make(preprod).withSeed({ mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }) + const testAddressHex = Address.toHex(await tempClient.address()) + const genesisConfig: Config.ShelleyGenesis = { + ...Config.DEFAULT_SHELLEY_GENESIS, + slotLength: 0.02, + epochLength: 50, + activeSlotsCoeff: 1.0, + initialFunds: { [testAddressHex]: 500_000_000_000 } + } + genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig) + devnetCluster = await Cluster.make({ + clusterName: "spend-datum-hash-test", + shelleyGenesis: genesisConfig, + kupo: { enabled: true, logLevel: "Info" }, + ogmios: { enabled: true, logLevel: "info" } + }) + await Cluster.start(devnetCluster) + await new Promise((resolve) => setTimeout(resolve, 3_000)) + }, 180_000) + + afterAll(async () => { + if (devnetCluster) { + await Cluster.stop(devnetCluster) + await Cluster.remove(devnetCluster) + } + }, 60_000) + + it("locks two outputs under each datum hash and publishes the datums", { timeout: 120_000 }, async () => { + const client = createTestClient() + let builder = client.newTx() + for (const datumHex of [...cases.flatMap(({ datumHex }) => [datumHex, datumHex]), UNPUBLISHED_HEX]) { + builder = builder.payToAddress({ + address: scriptAddress, + assets: Cardano.Assets.fromLovelace(10_000_000n), + datum: DatumHash.fromHex(hashOf(datumHex)) + }) + } + const built = await (await builder.build({ availableUtxos: [...genesisUtxos] })).toTransaction() + + // Publish the datums as supplemental datums; pay their bytes from the change + const datums = cases.map(({ datumHex }) => withBytes(datumHex)) + const feeBump = 20_000n + const changeIndex = built.body.outputs.length - 1 + const tx = new Transaction.Transaction({ + body: new TransactionBody.TransactionBody({ + ...built.body, + outputs: built.body.outputs.map((output, i) => + i === changeIndex + ? new TxOut.TransactionOutput({ ...output, assets: Cardano.Assets.subtractLovelace(output.assets, feeBump) }) + : output + ), + fee: built.body.fee + feeBump, + scriptDataHash: Redeemers.toScriptDataHash( + new Redeemers.RedeemerArray({ value: [] }), + new CostModel.CostModels({ + PlutusV1: new CostModel.CostModel({ costs: [] }), + PlutusV2: new CostModel.CostModel({ costs: [] }), + PlutusV3: new CostModel.CostModel({ costs: [] }) + }), + datums + ) + }), + witnessSet: new TransactionWitnessSet.TransactionWitnessSet({ ...built.witnessSet, plutusData: datums }), + isValid: true, + auxiliaryData: built.auxiliaryData + }) + // Sign the object, not decoded bytes, so each datum keeps its bytes + const walletWitnessSet = await client.signTx(Transaction.toCBORHex(tx), { utxos: [...genesisUtxos] }) + const signedHex = Transaction.toCBORHex(Transaction.addVKeyWitnesses(tx, walletWitnessSet.vkeyWitnesses ?? [])) + for (const { datumHex } of cases) expect(signedHex).toContain(datumHex) + + const submitted = await ogmios("submitTransaction", { transaction: { cbor: signedHex } }) + expect(submitted.error).toBeUndefined() + expect(await client.awaitTx(TransactionHash.fromHex(submitted.result.transaction.id), 1000)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + + for (const { datumHex } of cases) expect(await lockedUnder(datumHex)).toHaveLength(2) + }) + + for (const { datumHex, name } of cases) { + it(`spends the ${name} datum output with the datum given to collectFrom`, { timeout: 120_000 }, async () => { + const client = createTestClient() + const walletAddress = await client.address() + const [utxo] = await lockedUnder(datumHex) + if (utxo?.datumOption?._tag !== "DatumHash") throw new Error("expected a datum hash output") + + const spend = (datums?: ReadonlyArray) => + client + .newTx() + .collectFrom({ inputs: [utxo], redeemer, datums }) + .attachScript({ script }) + .payToAddress({ address: walletAddress, assets: Cardano.Assets.fromLovelace(5_000_000n) }) + + // No datum: the build fails before any evaluation and names both ways to give it + await expect(spend().build()).rejects.toThrow('onMissingDatum: "provider"') + + const datum = await client.getDatum(utxo.datumOption) + for (const evaluator of [createAikenEvaluator, createScalusEvaluator]) { + const evaluated = await (await spend([datum]).build({ evaluator })).toTransaction() + expect(Transaction.toCBORHex(evaluated)).toContain(`d9010281${datumHex}`) + } + + const signBuilder = await spend([datum]).build() + expect(Transaction.toCBORHex(await signBuilder.toTransaction())).toContain(`d9010281${datumHex}`) + const txHash = await (await signBuilder.sign()).submit() + expect(await client.awaitTx(txHash, 1000)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 1_000)) + }) + + it(`spends the ${name} datum output with onMissingDatum: "provider" under canonical options`, { timeout: 120_000 }, async () => { + const client = createTestClient() + const walletAddress = await client.address() + const [utxo] = await lockedUnder(datumHex) + expect(utxo).toBeDefined() + + const spend = () => + client + .newTx() + .collectFrom({ inputs: [utxo!], redeemer }) + .attachScript({ script }) + .payToAddress({ address: walletAddress, assets: Cardano.Assets.fromLovelace(5_000_000n) }) + const options = { onMissingDatum: "provider", codecOptions: CBOR.TX_CANONICAL_OPTIONS } as const + + for (const evaluator of [createAikenEvaluator, createScalusEvaluator]) { + const evaluated = await (await spend().build({ ...options, evaluator })).toTransaction() + expect(Transaction.toCBORHex(evaluated)).toContain(`d9010281${datumHex}`) + } + + const signBuilder = await spend().build(options) + expect(Transaction.toCBORHex(await signBuilder.toTransaction())).toContain(`d9010281${datumHex}`) + const txHash = await (await signBuilder.sign()).submit() + expect(await client.awaitTx(txHash, 1000)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 1_000)) + expect(await lockedUnder(datumHex)).toHaveLength(0) + }) + } + + it("fails to fetch a datum no transaction published", { timeout: 120_000 }, async () => { + const client = createTestClient() + const [utxo] = await lockedUnder(UNPUBLISHED_HEX) + expect(utxo).toBeDefined() + await expect( + client + .newTx() + .collectFrom({ inputs: [utxo!], redeemer }) + .attachScript({ script }) + .build({ onMissingDatum: "provider" }) + ).rejects.toThrow("provider.getDatum failed for datum hash") + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.WitnessDatum.test.ts b/packages/evolution-devnet/test/TxBuilder.WitnessDatum.test.ts index 38ce9d6a6..6f9a88cd1 100644 --- a/packages/evolution-devnet/test/TxBuilder.WitnessDatum.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.WitnessDatum.test.ts @@ -4,17 +4,14 @@ * default is accepted. * * The builder locks funds at a script address under Data.toDatumHash, then - * builds the spend. The builder does not yet put datum hash datums in the - * witness set (txBuilder.ts, "datum resolution not yet implemented"), so the - * test adds the datum to the built transaction, recomputes the script data - * hash with Redeemers.toScriptDataHash, encodes with Transaction.toCBORHex, - * signs with Transaction.addVKeyWitnessesBytes, and submits. Each case uses - * one set of options for the script data hash and the encoding, and their - * plutusData options for the datum hash: no options, CBOR.CML_DEFAULT_OPTIONS, - * CBOR.TX_CANONICAL_OPTIONS, or plutusData set to CML_DATA_DEFAULT_OPTIONS. The - * node accepts the spend only when the written datum hashes to the locked - * datum hash and the script data hash covers the datum and redeemer as - * written. + * builds the spend with the datum given to collectFrom. The test encodes the + * built transaction with Transaction.toCBORHex, signs it with + * Transaction.addVKeyWitnessesBytes, and submits. Each case builds with one + * set of codec options, and hashes the datum with their plutusData options: + * no options, CBOR.CML_DEFAULT_OPTIONS, CBOR.TX_CANONICAL_OPTIONS, or + * plutusData set to CML_DATA_DEFAULT_OPTIONS. The node accepts the spend only + * when the written datum hashes to the locked datum hash and the script data + * hash covers the datum and redeemer as written. * * A last spend goes through the builder alone, with an inline datum: the * builder writes its redeemer data in the data default and the node accepts it. @@ -28,19 +25,15 @@ import { Cardano, Client, preprod } from "@evolution-sdk/evolution" import * as Address from "@evolution-sdk/evolution/Address" import * as Bytes from "@evolution-sdk/evolution/Bytes" import * as CBOR from "@evolution-sdk/evolution/CBOR" -import * as CostModel from "@evolution-sdk/evolution/CostModel" import * as Data from "@evolution-sdk/evolution/Data" import * as DatumHash from "@evolution-sdk/evolution/DatumHash" import * as InlineDatum from "@evolution-sdk/evolution/InlineDatum" import * as PlutusV3 from "@evolution-sdk/evolution/PlutusV3" -import * as Redeemers from "@evolution-sdk/evolution/Redeemers" -import * as ScriptDataHash from "@evolution-sdk/evolution/ScriptDataHash" import * as ScriptHash from "@evolution-sdk/evolution/ScriptHash" import * as Transaction from "@evolution-sdk/evolution/Transaction" import * as TransactionBody from "@evolution-sdk/evolution/TransactionBody" import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" import * as TransactionWitnessSet from "@evolution-sdk/evolution/TransactionWitnessSet" -import * as TxOut from "@evolution-sdk/evolution/TxOut" import plutusJson from "../../evolution/test/spec/plutus.json" @@ -195,59 +188,17 @@ describe("Witness datum layout (Devnet Submit)", () => { ) expect(scriptUtxo).toBeDefined() - // Build the spend; the builder leaves the witness datum out + // The builder writes the datum given to collectFrom, with the build's plutusData options const spendSignBuilder = await client .newTx() - .collectFrom({ inputs: [scriptUtxo!], redeemer: mapAndList }) + .collectFrom({ inputs: [scriptUtxo!], redeemer: mapAndList, datums: [datum] }) .attachScript({ script }) .payToAddress({ address: walletAddress, assets: Cardano.Assets.fromLovelace(5_000_000n) }) - .build() - const built = await spendSignBuilder.toTransaction() - expect(built.witnessSet.plutusData ?? []).toHaveLength(0) - const redeemers = built.witnessSet.redeemers! - expect(redeemers.size).toBe(1) + .build({ codecOptions: options }) + const tx = await spendSignBuilder.toTransaction() + expect(tx.witnessSet.plutusData ?? []).toHaveLength(1) - // The builder hashed the PlutusV3 language view only - const params = await client.getProtocolParameters() - const costModels = new CostModel.CostModels({ - PlutusV1: new CostModel.CostModel({ costs: [] }), - PlutusV2: new CostModel.CostModel({ costs: [] }), - PlutusV3: new CostModel.CostModel({ costs: Object.values(params.costModels.PlutusV3).map((v) => BigInt(v)) }) - }) - expect(ScriptDataHash.toHex(Redeemers.toScriptDataHash(redeemers, costModels))).toBe( - ScriptDataHash.toHex(built.body.scriptDataHash!) - ) - - // Add the datum and its hash; pay the few extra bytes from the change output - const feeBump = 10_000n - const changeIndex = built.body.outputs.reduce( - (best, output, i) => - Cardano.Assets.lovelaceOf(output.assets) > Cardano.Assets.lovelaceOf(built.body.outputs[best]!.assets) - ? i - : best, - 0 - ) - const outputs = built.body.outputs.map((output, i) => - i === changeIndex - ? new TxOut.TransactionOutput({ - ...output, - assets: Cardano.Assets.subtractLovelace(output.assets, feeBump) - }) - : output - ) - const tx = new Transaction.Transaction({ - body: new TransactionBody.TransactionBody({ - ...built.body, - outputs, - fee: built.body.fee + feeBump, - scriptDataHash: Redeemers.toScriptDataHash(redeemers, costModels, [datum], options) - }), - witnessSet: new TransactionWitnessSet.TransactionWitnessSet({ ...built.witnessSet, plutusData: [datum] }), - isValid: built.isValid, - auxiliaryData: built.auxiliaryData - }) - - const txHex = Transaction.toCBORHex(tx, options) + const txHex = Transaction.toCBORHex(tx) const txBytes = Bytes.fromHex(txHex) const walletUtxos = await client.getUtxos(walletAddress) const walletWitnessSet = await client.signTx(txHex, { utxos: [...walletUtxos, scriptUtxo!] }) From c7288b05d8fef85734735adbf046662d384e1a1e Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 07/10] docs: spend datum hash outputs --- .../docs/introduction/important-defaults.mdx | 2 +- docs/content/docs/smart-contracts/datums.mdx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/introduction/important-defaults.mdx b/docs/content/docs/introduction/important-defaults.mdx index 6070ade55..272d4bf27 100644 --- a/docs/content/docs/introduction/important-defaults.mdx +++ b/docs/content/docs/introduction/important-defaults.mdx @@ -95,7 +95,7 @@ Data.toDatumHash(datum, CBOR.CML_DATA_DEFAULT_OPTIONS) Transaction.toCBORHex(tx, { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFAULT_OPTIONS }) ``` -The `plutusData` transaction options cover every new Plutus data item: inline datums, witness datums and redeemer data. A datum decoded from a transaction keeps its bytes. +The `plutusData` transaction options cover every new Plutus data item: inline datums, witness datums and redeemer data. A datum decoded from a transaction, or fetched with `getDatum`, keeps its bytes. ### Scripts Are Double-CBOR Encoded diff --git a/docs/content/docs/smart-contracts/datums.mdx b/docs/content/docs/smart-contracts/datums.mdx index ddbafbd52..31a86eaa3 100644 --- a/docs/content/docs/smart-contracts/datums.mdx +++ b/docs/content/docs/smart-contracts/datums.mdx @@ -69,6 +69,19 @@ const tx = await client .build() ``` +To spend the output, give its datum to `collectFrom`. The builder matches each datum to its input by hash and writes it in the witness set: + +```typescript +// lockedUtxo: the output locked above; script: the validator at its address +const spendTx = await client + .newTx() + .collectFrom({ inputs: [lockedUtxo], redeemer: Data.constr(0n, []), datums: [datum] }) + .attachScript({ script }) + .build() +``` + +A datum built by hand must be in the layout its hash was computed with, as `datum` is here. For an output someone else locked, fetch the datum with `client.getDatum(lockedUtxo.datumOption)`, which keeps the bytes its hash covers, or let the build fetch each missing datum with `build({ onMissingDatum: "provider" })`. A provider can serve a datum only after some transaction has published it. With neither, `build()` fails and names each input that needs its datum. + ## Type-Safe Datums with TSchema For production contracts, define your datum structure with TSchema to get compile-time type checking and automatic CBOR encoding: From 0a6063e19a624c9c84bf321fac745e0f6d899697 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 16:51:11 -0600 Subject: [PATCH 08/10] release: changeset for datum hash spends --- .changeset/spend-datum-hash.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/spend-datum-hash.md diff --git a/.changeset/spend-datum-hash.md b/.changeset/spend-datum-hash.md new file mode 100644 index 000000000..cd9fe006f --- /dev/null +++ b/.changeset/spend-datum-hash.md @@ -0,0 +1,28 @@ +--- +"@evolution-sdk/evolution": minor +--- + +The transaction builder now spends outputs that hold a datum hash. `collectFrom` takes the datum of each such input in a new `datums` field, and `build({ onMissingDatum: "provider" })` fetches the missing ones with `provider.getDatum`. Before, the builder left the datum out of the witness set, and the node rejected the spend. + +```ts +const datum = await client.getDatum(utxo.datumOption) +await client.newTx() + .collectFrom({ inputs: [utxo], redeemer, datums: [datum] }) + .attachScript({ script }) + .build() + +// or fetch each missing datum during the build +await client.newTx() + .collectFrom({ inputs: [utxo], redeemer }) + .attachScript({ script }) + .build({ onMissingDatum: "provider" }) +``` + +A spend of a datum-hash output at a Plutus script address now fails at `build()` when its datum is neither given nor fetched, and the error names each such input. Code that added the datum to the built transaction by hand should pass it to `collectFrom` instead. + +- The built-in providers' `getDatum` keeps the bytes it fetched, through `OriginalBytes`. The witness set and the script data hash write a datum with those bytes, whatever the codec options say, because the datum hash covers them. A datum without recorded bytes is written with the `plutusData` options, as before. +- The builder matches each given datum to its input by hashing the bytes it will write. It fails when a given datum belongs to no spent input. +- The fee now counts the witness datums. +- `TransactionWitnessSet` keeps each datum as the same object when it is built from another witness set, so the recorded bytes survive signing. +- `SignBuilder.assemble` keeps the transaction's own datums and drops the datums of the given witness sets, since the body's script data hash covers only its own. +- `TxBuilderState` has a new `datums` field. From 2f32452ad26b680d05c9a2f88bde2ed43cf00d81 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 17:09:41 -0600 Subject: [PATCH 09/10] fix(builder): say the provider lookup finds only published datums --- docs/content/docs/smart-contracts/datums.mdx | 2 +- packages/evolution/src/sdk/builders/TransactionBuilder.ts | 5 +++++ packages/evolution/src/sdk/builders/internal/txBuilder.ts | 5 ++++- packages/evolution/test/TxBuilder.SpendDatumHash.test.ts | 4 +++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/smart-contracts/datums.mdx b/docs/content/docs/smart-contracts/datums.mdx index 31a86eaa3..bd27c79c1 100644 --- a/docs/content/docs/smart-contracts/datums.mdx +++ b/docs/content/docs/smart-contracts/datums.mdx @@ -80,7 +80,7 @@ const spendTx = await client .build() ``` -A datum built by hand must be in the layout its hash was computed with, as `datum` is here. For an output someone else locked, fetch the datum with `client.getDatum(lockedUtxo.datumOption)`, which keeps the bytes its hash covers, or let the build fetch each missing datum with `build({ onMissingDatum: "provider" })`. A provider can serve a datum only after some transaction has published it. With neither, `build()` fails and names each input that needs its datum. +A datum built by hand must be in the layout its hash was computed with, as `datum` is here. For an output someone else locked, fetch the datum with `client.getDatum(lockedUtxo.datumOption)`, which keeps the bytes its hash covers, or let the build fetch each missing datum with `build({ onMissingDatum: "provider" })`. A provider can serve a datum only after an earlier transaction has published it. Outputs this SDK locks never publish theirs, so keep the datum of each output you lock. With neither, `build()` fails and names each input that needs its datum. ## Type-Safe Datums with TSchema diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index e372eb7eb..17e731fd0 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -546,6 +546,11 @@ export interface BuildOptions { * `provider.getDatum`, checks that its bytes hash to the datum hash, and * writes those bytes to the witness set. * + * A provider finds a datum only once an earlier transaction has published + * it, in its witness set or as an inline datum. Outputs this SDK locks + * under a datum hash never publish their datum, so keep that datum and + * pass it to `collectFrom({ datums })`. + * * @example * ```typescript * const signBuilder = await client diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index 8891a0386..be09b6f25 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -468,7 +468,10 @@ export const resolveSpendDatums: Effect.Effect< Effect.mapError( (error) => new TransactionBuilderError({ - message: `provider.getDatum failed for datum hash ${expected} of input ${input}: ${error.message}`, + message: + `provider.getDatum failed for datum hash ${expected} of input ${input}: ${error.message}. ` + + "A provider finds a datum only once an earlier transaction has published it; " + + "for an output this SDK locked, pass its datum to collectFrom({ datums })", cause: error }) ) diff --git a/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts b/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts index 55fcc546d..cad80b3e2 100644 --- a/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts +++ b/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts @@ -379,7 +379,9 @@ describe("onMissingDatum: provider", () => { it("fails when the provider has no datum", async () => { await expect( build({ inputs: [lockedUtxo("d87982a101028103")], onMissingDatum: "provider" }).built - ).rejects.toThrow("provider.getDatum failed for datum hash") + ).rejects.toThrow( + /provider\.getDatum failed for datum hash .*for an output this SDK locked, pass its datum to collectFrom/ + ) }) it("fails when the fetched datum does not hash to the datum hash", async () => { From c55853e1184f84cf9ba5914ed93c02960ae4f0b6 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 21 Oct 2026 17:09:41 -0600 Subject: [PATCH 10/10] release: note the provider lookup limit in the changeset --- .changeset/spend-datum-hash.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/spend-datum-hash.md b/.changeset/spend-datum-hash.md index cd9fe006f..04ff4349d 100644 --- a/.changeset/spend-datum-hash.md +++ b/.changeset/spend-datum-hash.md @@ -20,6 +20,8 @@ await client.newTx() A spend of a datum-hash output at a Plutus script address now fails at `build()` when its datum is neither given nor fetched, and the error names each such input. Code that added the datum to the built transaction by hand should pass it to `collectFrom` instead. +The `onMissingDatum: "provider"` lookup only finds datums an earlier transaction published, and outputs this SDK locks never publish theirs. For those, keep the datum and pass it to `collectFrom`. + - The built-in providers' `getDatum` keeps the bytes it fetched, through `OriginalBytes`. The witness set and the script data hash write a datum with those bytes, whatever the codec options say, because the datum hash covers them. A datum without recorded bytes is written with the `plutusData` options, as before. - The builder matches each given datum to its input by hashing the bytes it will write. It fails when a given datum belongs to no spent input. - The fee now counts the witness datums.