diff --git a/.changeset/committee-cert-redeemer.md b/.changeset/committee-cert-redeemer.md new file mode 100644 index 00000000..05fb94a0 --- /dev/null +++ b/.changeset/committee-cert-redeemer.md @@ -0,0 +1,7 @@ +--- +"@evolution-sdk/evolution": patch +--- + +`authCommitteeHot` and `resignCommitteeCold` now build with a Plutus-script cold credential. The builder emits the certificate's redeemer at the certificate's index and evaluates it. Before, neither transaction assembly nor script evaluation matched the committee cold credential to its certificate, so the build failed in evaluation. + +A redeemer that the builder cannot place at an index now fails `build()` and names the redeemer key. Before, the builder logged a warning and left the redeemer out of the transaction, which the node then rejected. `mintAssets` no longer registers a redeemer for a policy whose amounts are all zero, since that policy is not minted. diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index be09b6f2..f15c179f 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -11,6 +11,7 @@ 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 Credential from "../../../Credential.js" import * as PlutusData from "../../../Data.js" import type * as DatumHash from "../../../DatumHash.js" import type * as DatumOption from "../../../DatumOption.js" @@ -289,6 +290,14 @@ const voterScriptHashHex = (voter: VotingProcedures.Voter): string | undefined = } } +/** The credential a certificate's `cert:{credentialHex}` redeemer key names, if any. */ +export const certCredential = (certificate: Certificate.Certificate): Credential.Credential | undefined => { + if ("stakeCredential" in certificate) return certificate.stakeCredential + if ("drepCredential" in certificate) return certificate.drepCredential + if ("committeeColdCredential" in certificate) return certificate.committeeColdCredential + return undefined +} + /** * Resolve the governing credential hash (as hex) for certificates whose authorization * can be script-controlled: the DRep lifecycle certificates (DRep credential) and the @@ -300,14 +309,11 @@ const certScriptHashHex = (certificate: Certificate.Certificate): string | undef case "RegDrepCert": case "UpdateDrepCert": case "UnregDrepCert": - return certificate.drepCredential._tag === "ScriptHash" - ? Bytes.toHex(certificate.drepCredential.hash) - : undefined case "AuthCommitteeHotCert": - case "ResignCommitteeColdCert": - return certificate.committeeColdCredential._tag === "ScriptHash" - ? Bytes.toHex(certificate.committeeColdCredential.hash) - : undefined + case "ResignCommitteeColdCert": { + const credential = certCredential(certificate)! + return credential._tag === "ScriptHash" ? Bytes.toHex(credential.hash) : undefined + } default: return undefined } @@ -818,19 +824,14 @@ const makeRedeemerIndexer = ( case "mint": return mintIndexMap.get(key) case "cert": { - // Key format: `cert:{credentialHex}`, matched against the stake - // credential or the DRep credential of each certificate in order + // Key format: `cert:{credentialHex}` const credentialHex = key.slice(5) - for (let i = 0; i < state.certificates.length; i++) { - const cert = state.certificates[i]! - if ("stakeCredential" in cert && cert.stakeCredential) { - if (Bytes.toHex((cert.stakeCredential as { hash: Uint8Array }).hash) === credentialHex) return i - } - if ("drepCredential" in cert && cert.drepCredential) { - if (Bytes.toHex((cert.drepCredential as { hash: Uint8Array }).hash) === credentialHex) return i - } - } - return undefined + return found( + state.certificates.findIndex((cert) => { + const credential = certCredential(cert) + return credential !== undefined && Bytes.toHex(credential.hash) === credentialHex + }) + ) } case "reward": // Key format: `reward:{credentialHex}` @@ -975,8 +976,12 @@ export const assembleTransaction = ( const redeemerIndex = redeemerIndexOf(key, redeemerData.tag) if (redeemerIndex === undefined) { - yield* Effect.logWarning(`[Assembly] Could not find the ${redeemerData.tag} index for redeemer key: ${key}`) - continue + return yield* Effect.fail( + new TransactionBuilderError({ + message: `Could not find the ${redeemerData.tag} index for redeemer key: ${key}`, + cause: { key, tag: redeemerData.tag } + }) + ) } yield* Effect.logDebug( @@ -1449,7 +1454,7 @@ export const buildFakeWitnessSet = ( const redeemerIndexOf = makeRedeemerIndexer(state, inputs) for (const [key, redeemerData] of state.redeemers) { // The index assembly writes: an index of 24 or more takes one more CBOR - // byte, and the fee must cover it. Assembly leaves out a redeemer with no index + // byte, and the fee must cover it. Assembly fails on a redeemer with no index const index = redeemerIndexOf(key, redeemerData.tag) if (index === undefined) continue diff --git a/packages/evolution/src/sdk/builders/operations/Mint.ts b/packages/evolution/src/sdk/builders/operations/Mint.ts index 8012b402..a01f351d 100644 --- a/packages/evolution/src/sdk/builders/operations/Mint.ts +++ b/packages/evolution/src/sdk/builders/operations/Mint.ts @@ -65,12 +65,12 @@ export const createMintAssetsProgram = ( const policyIds = new Set() for (const [policyId, assetMap] of params.assets.multiAsset.map.entries()) { - // Track policy IDs for redeemer association - policyIds.add(PolicyId.toHex(policyId)) - for (const [assetName, amount] of assetMap.entries()) { if (amount === 0n) continue + // Track policy IDs for redeemer association + policyIds.add(PolicyId.toHex(policyId)) + // Validate amount is within NonZeroInt64 range if (amount < NonZeroInt64.NEG_INT64_MIN || amount > NonZeroInt64.POS_INT64_MAX) { return yield* Effect.fail( diff --git a/packages/evolution/src/sdk/builders/phases/Evaluation.ts b/packages/evolution/src/sdk/builders/phases/Evaluation.ts index 5f36f200..efd45ad9 100644 --- a/packages/evolution/src/sdk/builders/phases/Evaluation.ts +++ b/packages/evolution/src/sdk/builders/phases/Evaluation.ts @@ -17,7 +17,7 @@ import * as PolicyId from "../../../PolicyId.js" import * as CoreUTxO from "../../../UTxO.js" import type * as Provider from "../../provider/Provider.js" import * as EvaluationStateManager from "../EvaluationStateManager.js" -import { assembleTransaction } from "../internal/txBuilder.js" +import { assembleTransaction, certCredential } from "../internal/txBuilder.js" import type { IndexedInput } from "../RedeemerBuilder.js" import { BuildOptionsTag, @@ -401,20 +401,11 @@ export const executeEvaluation = (): Effect.Effect< // Certificate redeemers are indexed by their position in the certificates array const certIndexMapping = new Map() for (let i = 0; i < updatedState.certificates.length; i++) { - const cert = updatedState.certificates[i]! - // Handle stake-related certificates (stakeCredential) - if ("stakeCredential" in cert && cert.stakeCredential) { - const credHex = Bytes.toHex(cert.stakeCredential.hash) - const key = `cert:${credHex}` + const credential = certCredential(updatedState.certificates[i]!) + if (credential !== undefined) { + const key = `cert:${Bytes.toHex(credential.hash)}` certIndexMapping.set(i, key) - yield* Effect.logDebug(`[Evaluation] Cert ${i} maps to stake credential: ${key}`) - } - // Handle DRep-related certificates (drepCredential): RegDrepCert, UnregDrepCert, UpdateDrepCert - else if ("drepCredential" in cert && cert.drepCredential) { - const credHex = Bytes.toHex(cert.drepCredential.hash) - const key = `cert:${credHex}` - certIndexMapping.set(i, key) - yield* Effect.logDebug(`[Evaluation] Cert ${i} maps to drep credential: ${key}`) + yield* Effect.logDebug(`[Evaluation] Cert ${i} maps to credential: ${key}`) } } diff --git a/packages/evolution/test/TxBuilder.MintEmptyPolicy.test.ts b/packages/evolution/test/TxBuilder.MintEmptyPolicy.test.ts new file mode 100644 index 00000000..2e7e5955 --- /dev/null +++ b/packages/evolution/test/TxBuilder.MintEmptyPolicy.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import * as CoreAddress from "../src/Address.js" +import * as CoreAssets from "../src/Assets.js" +import * as Bytes from "../src/Bytes.js" +import * as Data from "../src/Data.js" +import * as PlutusV3 from "../src/PlutusV3.js" +import * as Redeemer from "../src/Redeemer.js" +import * as ScriptHash from "../src/ScriptHash.js" +import type { Evaluator } from "../src/sdk/builders/TransactionBuilder.js" +import { makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" +import { mainnet } from "../src/sdk/client/index.js" +import type { ProtocolParameters } from "../src/sdk/provider/Provider.js" +import * as Text from "../src/Text.js" +import type * as Transaction from "../src/Transaction.js" +import plutusJson from "./spec/plutus.json" +import { createCoreTestUtxo } from "./utils/utxo-helpers.js" + +const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" + +const PROTOCOL_PARAMS = { + minFeeA: 44, + minFeeB: 155_381, + maxTxSize: 16_384, + maxValSize: 5_000, + keyDeposit: 2_000_000n, + poolDeposit: 500_000_000n, + drepDeposit: 500_000_000n, + govActionDeposit: 100_000_000_000n, + priceMem: 0.0577, + priceStep: 0.0000721, + maxTxExMem: 14_000_000n, + maxTxExSteps: 10_000_000_000n, + coinsPerUtxoByte: 4_310n, + collateralPercentage: 150, + maxCollateralInputs: 3, + minFeeRefScriptCostPerByte: 15, + costModels: { + PlutusV1: {} as Record, + PlutusV2: {} as Record, + PlutusV3: {} as Record + } +} satisfies ProtocolParameters + +const loadScript = (title: string) => + new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(plutusJson.validators.find((v) => v.title === title)!.compiledCode) }) + +const script = loadScript("simple_mint.simple_mint.mint") +const unit = ScriptHash.toHex(ScriptHash.fromScript(script)) + Text.toHex("Token") +const emptyUnit = + ScriptHash.toHex(ScriptHash.fromScript(loadScript("always_succeed.always_succeed.spend"))) + Text.toHex("Token") +const redeemer = Data.constr(0n, []) + +const evaluator: Evaluator = { + evaluate: (tx) => + Effect.sync(() => + (tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({ + redeemer_tag: r.tag, + redeemer_index: Number(r.index), + ex_units: new Redeemer.ExUnits({ mem: 10_000n, steps: 5_000_000n }) + })) + ) +} + +const buildOptions = () => ({ + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: [ + createCoreTestUtxo({ transactionId: "a".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 2_000_000_000n }), + createCoreTestUtxo({ transactionId: "b".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 20_000_000n }) + ], + fullProtocolParameters: PROTOCOL_PARAMS, + evaluator +}) + +const redeemersOf = (tx: Transaction.Transaction) => + (tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({ tag: r.tag, index: r.index })) + +describe("TxBuilder mint policy with nothing to mint", () => { + it("takes no redeemer for a policy whose amounts are all zero", async () => { + const tx = await makeTxBuilder({ chain: mainnet }) + .mintAssets({ assets: CoreAssets.fromRecord({ [unit]: 1n, [emptyUnit]: 0n }), redeemer }) + .attachScript({ script }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.body.mint?.map.size).toBe(1) + expect(redeemersOf(tx)).toEqual([{ tag: "mint", index: 0n }]) + }) +}) diff --git a/packages/evolution/test/TxBuilder.PlutusScriptCCCert.test.ts b/packages/evolution/test/TxBuilder.PlutusScriptCCCert.test.ts new file mode 100644 index 00000000..76646d90 --- /dev/null +++ b/packages/evolution/test/TxBuilder.PlutusScriptCCCert.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import * as CoreAddress from "../src/Address.js" +import * as Bytes from "../src/Bytes.js" +import * as Data from "../src/Data.js" +import * as KeyHash from "../src/KeyHash.js" +import * as PlutusV3 from "../src/PlutusV3.js" +import * as Redeemer from "../src/Redeemer.js" +import * as ScriptHash from "../src/ScriptHash.js" +import type { Evaluator } from "../src/sdk/builders/TransactionBuilder.js" +import { makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" +import { mainnet } from "../src/sdk/client/index.js" +import type { ProtocolParameters } from "../src/sdk/provider/Provider.js" +import type * as Transaction from "../src/Transaction.js" +import plutusJson from "./spec/plutus.json" +import { createCoreTestUtxo } from "./utils/utxo-helpers.js" + +const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" + +const PROTOCOL_PARAMS = { + minFeeA: 44, + minFeeB: 155_381, + maxTxSize: 16_384, + maxValSize: 5_000, + keyDeposit: 2_000_000n, + poolDeposit: 500_000_000n, + drepDeposit: 500_000_000n, + govActionDeposit: 100_000_000_000n, + priceMem: 0.0577, + priceStep: 0.0000721, + maxTxExMem: 14_000_000n, + maxTxExSteps: 10_000_000_000n, + coinsPerUtxoByte: 4_310n, + collateralPercentage: 150, + maxCollateralInputs: 3, + minFeeRefScriptCostPerByte: 15, + costModels: { + PlutusV1: {} as Record, + PlutusV2: {} as Record, + PlutusV3: {} as Record + } +} satisfies ProtocolParameters + +const script = new PlutusV3.PlutusV3({ + bytes: Bytes.fromHex( + plutusJson.validators.find((v) => v.title === "always_succeed.always_succeed.spend")!.compiledCode + ) +}) +const scriptColdCredential = ScriptHash.fromScript(script) +const keyColdCredential = new KeyHash.KeyHash({ hash: new Uint8Array(28).fill(0x22) }) +const hotCredential = new KeyHash.KeyHash({ hash: new Uint8Array(28).fill(0x11) }) +const redeemer = Data.constr(0n, []) + +const evaluator: Evaluator = { + evaluate: (tx) => + Effect.sync(() => + (tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({ + redeemer_tag: r.tag, + redeemer_index: Number(r.index), + ex_units: new Redeemer.ExUnits({ mem: 10_000n, steps: 5_000_000n }) + })) + ) +} + +const buildOptions = () => ({ + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: [ + createCoreTestUtxo({ transactionId: "a".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 2_000_000_000n }), + createCoreTestUtxo({ transactionId: "b".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 20_000_000n }) + ], + fullProtocolParameters: PROTOCOL_PARAMS, + evaluator +}) + +const redeemersOf = (tx: Transaction.Transaction) => + (tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({ tag: r.tag, index: r.index })) + +describe("TxBuilder Plutus-script CC certificate", () => { + it("authorizes a hot credential for a Plutus-script cold credential", async () => { + const tx = await makeTxBuilder({ chain: mainnet }) + .authCommitteeHot({ coldCredential: scriptColdCredential, hotCredential, redeemer }) + .attachScript({ script }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(redeemersOf(tx)).toEqual([{ tag: "cert", index: 0n }]) + expect(tx.witnessSet.plutusV3Scripts?.length).toBe(1) + expect(tx.body.scriptDataHash).toBeDefined() + }) + + it("indexes the redeemer at the committee certificate's own position", async () => { + const tx = await makeTxBuilder({ chain: mainnet }) + .authCommitteeHot({ coldCredential: keyColdCredential, hotCredential }) + .authCommitteeHot({ coldCredential: scriptColdCredential, hotCredential, redeemer }) + .attachScript({ script }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.body.certificates?.length).toBe(2) + expect(redeemersOf(tx)).toEqual([{ tag: "cert", index: 1n }]) + }) + + it("resigns a Plutus-script cold credential", async () => { + const tx = await makeTxBuilder({ chain: mainnet }) + .resignCommitteeCold({ coldCredential: scriptColdCredential, anchor: null, redeemer }) + .attachScript({ script }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(redeemersOf(tx)).toEqual([{ tag: "cert", index: 0n }]) + }) + + it("emits no redeemer for a key-hash cold credential", async () => { + const tx = await makeTxBuilder({ chain: mainnet }) + .authCommitteeHot({ coldCredential: keyColdCredential, hotCredential, redeemer }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.witnessSet.redeemers).toBeUndefined() + }) +})