diff --git a/.changeset/spend-datum-hash.md b/.changeset/spend-datum-hash.md new file mode 100644 index 000000000..04ff4349d --- /dev/null +++ b/.changeset/spend-datum-hash.md @@ -0,0 +1,30 @@ +--- +"@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 `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. +- `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. 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..bd27c79c1 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 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 For production contracts, define your datum structure with TSchema to get compile-time type checking and automatic CBOR encoding: 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!] }) 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 ) ) 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 diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 35d5bfad1..17e731fd0 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,35 @@ 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. + * + * 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 + * .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..be09b6f25 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,189 @@ 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}. ` + + "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 + }) + ) + ) + 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 +1000,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 +1471,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 { 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) }) /** diff --git a/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts b/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts new file mode 100644 index 000000000..cad80b3e2 --- /dev/null +++ b/packages/evolution/test/TxBuilder.SpendDatumHash.test.ts @@ -0,0 +1,436 @@ +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 .*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 () => { + 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. */