From de780f887d04b8b8206bc22c1e06623250993c7f Mon Sep 17 00:00:00 2001 From: Tommy Date: Tue, 6 Oct 2026 16:34:58 +0200 Subject: [PATCH 1/4] feat(builders): run constitution guardrail script in propose() ProposeParams takes an optional redeemer and label. The redeemer is tracked under the propose purpose, keyed by the proposal's position in proposalProcedures, and is indexed, evaluated and balanced like the other script redeemers. The guardrail script is supplied with attachScript() or readFrom(). The build fails early when a proposal with a Plutus policyHash has no redeemer, or when a redeemer is supplied for a proposal that runs no guardrail. Redeemers for native-script guardrails are dropped. The devnet ConwayGenesis type accepts constitution.script, and a devnet test submits TreasuryWithdrawals and ParameterChange proposals against an always-succeeding guardrail, with the script attached and via a reference input. Closes #594 --- .changeset/propose-guardrail-redeemer.md | 8 + docs/content/docs/governance/proposals.mdx | 35 +++ packages/evolution-devnet/src/Config.ts | 2 + .../test/TxBuilder.ProposeGuardrail.test.ts | 226 ++++++++++++++++++ .../src/sdk/builders/TransactionBuilder.ts | 33 ++- .../src/sdk/builders/internal/build.ts | 1 + .../src/sdk/builders/internal/txBuilder.ts | 115 ++++++++- .../src/sdk/builders/operations/Operations.ts | 9 + .../src/sdk/builders/operations/Propose.ts | 44 +++- .../src/sdk/builders/phases/Evaluation.ts | 54 ++++- .../test/TxBuilder.ProposeGuardrail.test.ts | 208 ++++++++++++++++ packages/evolution/test/spec/plutus.json | 19 ++ .../spec/validators/governance_guardrail.ak | 15 ++ 13 files changed, 760 insertions(+), 9 deletions(-) create mode 100644 .changeset/propose-guardrail-redeemer.md create mode 100644 packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts create mode 100644 packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts create mode 100644 packages/evolution/test/spec/validators/governance_guardrail.ak diff --git a/.changeset/propose-guardrail-redeemer.md b/.changeset/propose-guardrail-redeemer.md new file mode 100644 index 00000000..6cf2d960 --- /dev/null +++ b/.changeset/propose-guardrail-redeemer.md @@ -0,0 +1,8 @@ +--- +"@evolution-sdk/evolution": patch +"@evolution-sdk/devnet": patch +--- + +`.propose()` can now run the constitution's guardrail script. `ProposeParams` takes an optional `redeemer` (and `label`), which the builder adds under the propose purpose at the proposal's index, evaluates and balances like any other script redeemer. Provide the guardrail script with `.attachScript()` or `.readFrom()`. This makes `TreasuryWithdrawalsAction` and `ParameterChangeAction` submittable on networks whose constitution has a guardrail, such as mainnet. The build now fails early when a proposal with a Plutus `policyHash` has no redeemer, or when a redeemer is given for a proposal that runs no guardrail. + +The devnet `ConwayGenesis` type accepts an optional `constitution.script` guardrail hash. diff --git a/docs/content/docs/governance/proposals.mdx b/docs/content/docs/governance/proposals.mdx index e99a312f..95309ac2 100644 --- a/docs/content/docs/governance/proposals.mdx +++ b/docs/content/docs/governance/proposals.mdx @@ -83,6 +83,41 @@ const signed = await tx.sign() await signed.submit() ``` +## Guardrail Script + +When the constitution defines a guardrail script (as on mainnet), `ParameterChangeAction` and `TreasuryWithdrawalsAction` must reference it in `policyHash` and run it under the propose purpose. Pass a `redeemer` to `.propose()` and provide the script with `.attachScript()` or a reference input via `.readFrom()`: + +```typescript twoslash +import { Anchor, Data, GovernanceAction, PlutusV3, RewardAccount, ScriptHash, preprod, Client } from "@evolution-sdk/evolution" + +const client = Client.make(preprod) + .withBlockfrost({ + baseUrl: "https://cardano-preprod.blockfrost.io/api/v0", + projectId: process.env.BLOCKFROST_API_KEY! + }) + .withSeed({ mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 }) + +declare const guardrailScript: PlutusV3.PlutusV3 // the constitution's guardrail script +declare const rewardAccount: RewardAccount.RewardAccount +declare const anchor: Anchor.Anchor + +const governanceAction = new GovernanceAction.TreasuryWithdrawalsAction({ + withdrawals: new Map([[rewardAccount, 1_000_000_000n]]), + policyHash: ScriptHash.fromScript(guardrailScript) +}) + +const tx = await client + .newTx() + .propose({ governanceAction, rewardAccount, anchor, redeemer: Data.constr(0n, []) }) + .attachScript({ script: guardrailScript }) + .build() + +const signed = await tx.sign() +await signed.submit() +``` + +The redeemer is placed at the proposal's index in the transaction, so guardrail-checked proposals can be combined with other proposals in one transaction. The build fails if a proposal with a Plutus `policyHash` has no redeemer. + ## Next Steps - [Voting](/docs/governance/voting) — Vote on submitted proposals diff --git a/packages/evolution-devnet/src/Config.ts b/packages/evolution-devnet/src/Config.ts index f1794d48..feb6f548 100644 --- a/packages/evolution-devnet/src/Config.ts +++ b/packages/evolution-devnet/src/Config.ts @@ -230,6 +230,8 @@ export type ConwayGenesis = { readonly url: string readonly dataHash: string } + /** Optional guardrail script hash (hex) checked for ParameterChange and TreasuryWithdrawals proposals */ + readonly script?: string } readonly committee: { readonly members: Record diff --git a/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts new file mode 100644 index 00000000..93d617d7 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts @@ -0,0 +1,226 @@ +/** + * Devnet tests for proposals checked by a constitution guardrail script. + * The devnet constitution references an always-succeeding PlutusV3 guardrail, + * so TreasuryWithdrawals and ParameterChange proposals must carry its policy + * hash, provide the script and run it under the propose purpose. + */ + +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest" +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 Anchor from "@evolution-sdk/evolution/Anchor" +import * as Assets from "@evolution-sdk/evolution/Assets" +import * as Bytes from "@evolution-sdk/evolution/Bytes" +import * as Bytes32 from "@evolution-sdk/evolution/Bytes32" +import * as Data from "@evolution-sdk/evolution/Data" +import * as GovernanceAction from "@evolution-sdk/evolution/GovernanceAction" +import * as PlutusV3 from "@evolution-sdk/evolution/PlutusV3" +import * as RewardAccount from "@evolution-sdk/evolution/RewardAccount" +import * as ScriptHash from "@evolution-sdk/evolution/ScriptHash" +import * as Url from "@evolution-sdk/evolution/Url" + +import plutusJson from "../../evolution/test/spec/plutus.json" + +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 guardrailScript = new PlutusV3.PlutusV3({ + bytes: Bytes.fromHex( + plutusJson.validators.find((v) => v.title === "governance_guardrail.always_yes_guardrail.propose")!.compiledCode + ) +}) +const guardrailHash = ScriptHash.fromScript(guardrailScript) + +const makeAnchor = (url: string) => + new Anchor.Anchor({ + anchorUrl: new Url.Url({ href: url }), + anchorDataHash: Bytes32.fromHex("0".repeat(64)) + }) + +describe("TxBuilder propose with guardrail script", () => { + let devnetCluster: Cluster.Cluster | undefined + let genesisUtxo: Cardano.UTxO.UTxO | undefined + let rewardAccount: RewardAccount.RewardAccount | undefined + + const createTestClient = (accountIndex: number = 0) => { + if (!devnetCluster) throw new Error("Cluster not initialized") + return Client.make(Cluster.getChain(devnetCluster)) + .withKupmios({ + kupoUrl: `http://localhost:${devnetCluster.ports.kupo}`, + ogmiosUrl: `http://localhost:${devnetCluster.ports.ogmios}` + }) + .withSeed({ mnemonic: TEST_MNEMONIC, accountIndex, addressType: "Base" }) + } + + const treasuryWithdrawal = (policyHash: ScriptHash.ScriptHash | null) => + new GovernanceAction.TreasuryWithdrawalsAction({ + withdrawals: new Map([[rewardAccount!, 1_000_000n]]), + policyHash + }) + + beforeAll(async () => { + const address = await Client.make(preprod) + .withSeed({ mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }) + .address() + + const genesisConfig: Config.ShelleyGenesis = { + ...Config.DEFAULT_SHELLEY_GENESIS, + slotLength: 0.02, + epochLength: 50, + activeSlotsCoeff: 1.0, + initialFunds: { [Address.toHex(address)]: 500_000_000_000 } + } + + const genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig) + genesisUtxo = genesisUtxos.find((u) => Address.toBech32(u.address) === Address.toBech32(address)) + + devnetCluster = await Cluster.make({ + clusterName: "propose-guardrail-test", + shelleyGenesis: genesisConfig, + conwayGenesis: { + ...Config.DEFAULT_CONWAY_GENESIS, + constitution: { + ...Config.DEFAULT_CONWAY_GENESIS.constitution, + script: ScriptHash.toHex(guardrailHash) + } + }, + kupo: { enabled: true, logLevel: "Info" }, + ogmios: { enabled: true, logLevel: "info" } + }) + + await Cluster.start(devnetCluster) + await new Promise((r) => setTimeout(r, 3_000)) + + // Treasury withdrawals must pay to a registered reward account + const client = createTestClient(0) + if (!address.stakingCredential) throw new Error("Need staking credential") + const stakeRegTx = await client + .newTx() + .registerStake({ stakeCredential: address.stakingCredential }) + .build({ availableUtxos: [genesisUtxo!] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + expect(await client.awaitTx(stakeRegTx, 1000)).toBe(true) + await new Promise((r) => setTimeout(r, 2_000)) + + rewardAccount = new RewardAccount.RewardAccount({ networkId: 0, stakeCredential: address.stakingCredential }) + }, 180_000) + + afterAll(async () => { + if (devnetCluster) { + await Cluster.stop(devnetCluster) + await Cluster.remove(devnetCluster) + } + }, 60_000) + + it("rejects a treasury withdrawal that skips the guardrail", { timeout: 120_000 }, async () => { + const client = createTestClient(0) + + await expect( + client + .newTx() + .propose({ + governanceAction: treasuryWithdrawal(null), + rewardAccount: rewardAccount!, + anchor: makeAnchor("https://example.com/treasury.json") + }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + ).rejects.toThrow(/guardrails hash/) + }) + + it("submits a treasury withdrawal with an attached guardrail script", { timeout: 120_000 }, async () => { + const client = createTestClient(0) + + const signBuilder = await client + .newTx() + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount: rewardAccount!, + anchor: makeAnchor("https://example.com/treasury.json"), + redeemer: Data.constr(0n, []), + label: "guardrail" + }) + .attachScript({ script: guardrailScript }) + .build() + + const tx = await signBuilder.toTransaction() + const redeemers = tx.witnessSet.redeemers!.toArray() + expect(redeemers).toHaveLength(1) + expect(redeemers[0]!.tag).toBe("propose") + expect(redeemers[0]!.exUnits.steps).toBeGreaterThan(0n) + + const txHash = await signBuilder.sign().then((b) => b.submit()) + expect(await client.awaitTx(txHash, 1000)).toBe(true) + await new Promise((r) => setTimeout(r, 2_000)) + }) + + it("submits a guardrail-checked proposal next to an info action", { timeout: 120_000 }, async () => { + const client = createTestClient(0) + + const txHash = await client + .newTx() + .propose({ + governanceAction: new GovernanceAction.InfoAction({}), + rewardAccount: rewardAccount!, + anchor: makeAnchor("https://example.com/info.json") + }) + .propose({ + governanceAction: new GovernanceAction.ParameterChangeAction({ + govActionId: null, + protocolParamUpdate: new Cardano.ProtocolParamUpdate.ProtocolParamUpdate({ maxTxSize: 16_385n }), + policyHash: guardrailHash + }), + rewardAccount: rewardAccount!, + anchor: makeAnchor("https://example.com/params.json"), + redeemer: Data.constr(0n, []) + }) + .attachScript({ script: guardrailScript }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(txHash, 1000)).toBe(true) + await new Promise((r) => setTimeout(r, 2_000)) + }) + + it("submits a treasury withdrawal using the guardrail from a reference input", { timeout: 120_000 }, async () => { + const client = createTestClient(0) + const holder = await createTestClient(1).address() + + // Deploy the guardrail as a reference script at an address the wallet does not spend from + const deployTx = await client + .newTx() + .payToAddress({ address: holder, assets: Assets.fromLovelace(20_000_000n), script: guardrailScript }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + expect(await client.awaitTx(deployTx, 1000)).toBe(true) + await new Promise((r) => setTimeout(r, 2_000)) + + const refUtxo = (await client.getUtxos(holder)).find((u) => u.scriptRef?._tag === "PlutusV3") + if (!refUtxo) throw new Error("Reference script UTxO not found") + + const signBuilder = await client + .newTx() + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount: rewardAccount!, + anchor: makeAnchor("https://example.com/treasury-ref.json"), + redeemer: Data.constr(0n, []) + }) + .readFrom({ referenceInputs: [refUtxo] }) + .build() + + const tx = await signBuilder.toTransaction() + expect(tx.witnessSet.plutusV3Scripts?.length ?? 0).toBe(0) + expect(tx.body.referenceInputs?.length).toBe(1) + + const txHash = await signBuilder.sign().then((b) => b.submit()) + expect(await client.awaitTx(txHash, 1000)).toBe(true) + }) +}) diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 0639b979..59eacf84 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -330,7 +330,7 @@ export interface ProtocolParameters { * @category state */ export interface RedeemerData { - readonly tag: "spend" | "mint" | "cert" | "reward" | "vote" + readonly tag: "spend" | "mint" | "cert" | "reward" | "vote" | "propose" readonly data: PlutusData.Data readonly exUnits?: { readonly mem: bigint @@ -348,7 +348,7 @@ export interface RedeemerData { * @category state */ export interface DeferredRedeemerData { - readonly tag: "spend" | "mint" | "cert" | "reward" | "vote" + readonly tag: "spend" | "mint" | "cert" | "reward" | "vote" | "propose" readonly deferred: DeferredRedeemer readonly exUnits?: { readonly mem: bigint @@ -751,6 +751,35 @@ export const voterToKey = (voter: VotingProcedures.Voter): string => { } } +// ============================================================================ +// Proposal Key +// ============================================================================ + +const PROPOSAL_KEY_PREFIX = "propose:" + +/** + * Convert a proposal's position in proposalProcedures to a unique string key + * for redeemer tracking. Proposals keep insertion order in the transaction, + * so the position is also the redeemer index. + * + * @since 2.0.0 + * @category utilities + */ +export const proposalToKey = (index: number): string => `${PROPOSAL_KEY_PREFIX}${index}` + +/** + * Parse a proposal redeemer key back to its position in proposalProcedures. + * Returns undefined for keys that are not proposal keys. + * + * @since 2.0.0 + * @category utilities + */ +export const proposalKeyToIndex = (key: string): number | undefined => { + if (!key.startsWith(PROPOSAL_KEY_PREFIX)) return undefined + const index = Number(key.slice(PROPOSAL_KEY_PREFIX.length)) + return Number.isInteger(index) && index >= 0 ? index : undefined +} + /** * Result type for transaction chaining operations. * diff --git a/packages/evolution/src/sdk/builders/internal/build.ts b/packages/evolution/src/sdk/builders/internal/build.ts index 43e32bfa..605c4112 100644 --- a/packages/evolution/src/sdk/builders/internal/build.ts +++ b/packages/evolution/src/sdk/builders/internal/build.ts @@ -168,6 +168,7 @@ export const makeBuild = ( yield* TxBuilderImpl.validateVoterRedeemers yield* TxBuilderImpl.validateCertRedeemers + yield* TxBuilderImpl.validateProposalRedeemers const { transaction, txWithFakeWitnesses } = yield* phaseStateMachine diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index 3d991e4d..c865fa3b 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -12,6 +12,7 @@ import * as CostModel from "../../../CostModel.js" import type * as PlutusData from "../../../Data.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 type * as PlutusV1 from "../../../PlutusV1.js" @@ -39,6 +40,8 @@ import * as Withdrawals from "../../../Withdrawals.js" import { BuildOptionsTag, FullProtocolParametersTag, + proposalKeyToIndex, + proposalToKey, TransactionBuilderError, type TxBuilderConfigTag, TxContext, @@ -464,6 +467,103 @@ export const validateCertRedeemers: Effect.Effect { + switch (action._tag) { + case "ParameterChangeAction": + case "TreasuryWithdrawalsAction": + return action.policyHash ? ScriptHash.toHex(action.policyHash) : undefined + default: + return undefined + } +} + +/** + * Validate redeemer requirements for governance proposals. Actions that carry a + * guardrail `policyHash` run the constitution script under the propose purpose, + * so a Plutus guardrail needs a redeemer. Redeemers supplied for proposals that + * run no script are rejected, since the ledger refuses extraneous redeemers. + * + * @since 2.0.0 + * @category validation + */ +export const validateProposalRedeemers: Effect.Effect = Effect.gen( + function* () { + const stateRef = yield* TxContext + const state = yield* Ref.get(stateRef) + + if (!state.proposalProcedures || state.proposalProcedures.procedures.length === 0) { + return + } + + const isNativeScript = makeIsNativeScript(state) + + const proposalsMissingRedeemer: Array = [] + const proposalsWithoutPolicy: Array = [] + const nativeProposalRedeemerKeys: Array = [] + + state.proposalProcedures.procedures.forEach((procedure, index) => { + const proposalKey = proposalToKey(index) + const hasRedeemer = state.redeemers.has(proposalKey) || state.deferredRedeemers.has(proposalKey) + const scriptHashHex = proposalPolicyHashHex(procedure.governanceAction) + + if (scriptHashHex === undefined) { + if (hasRedeemer) proposalsWithoutPolicy.push(proposalKey) + return + } + + if (isNativeScript(scriptHashHex) === true) { + if (hasRedeemer) nativeProposalRedeemerKeys.push(proposalKey) + } else if (!hasRedeemer) { + proposalsMissingRedeemer.push(`${proposalKey} (policy ${scriptHashHex})`) + } + }) + + if (proposalsMissingRedeemer.length > 0) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + `Redeemer required for ${proposalsMissingRedeemer.length} proposal(s) checked by a guardrail script: ` + + `${proposalsMissingRedeemer.join(", ")}. ` + + `Pass a redeemer to .propose() and provide the guardrail script via .attachScript() or a reference input.`, + cause: proposalsMissingRedeemer + }) + ) + } + + if (proposalsWithoutPolicy.length > 0) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + `Redeemer supplied for ${proposalsWithoutPolicy.length} proposal(s) without a guardrail policyHash: ` + + `${proposalsWithoutPolicy.join(", ")}. ` + + `Only ParameterChangeAction and TreasuryWithdrawalsAction with a policyHash run the guardrail script.`, + cause: proposalsWithoutPolicy + }) + ) + } + + if (nativeProposalRedeemerKeys.length > 0) { + yield* Effect.logDebug( + `[Propose] Ignoring redeemer(s) supplied for native-script guardrail(s): ${nativeProposalRedeemerKeys.join(", ")}. ` + + `Native scripts are satisfied by vkey witnesses, not redeemers.` + ) + yield* Ref.update(stateRef, (s) => { + const redeemers = new Map(s.redeemers) + const deferredRedeemers = new Map(s.deferredRedeemers) + for (const key of nativeProposalRedeemerKeys) { + redeemers.delete(key) + deferredRedeemers.delete(key) + } + return { ...s, redeemers, deferredRedeemers } + }) + } + } +) + /** * Assemble a Transaction from inputs, outputs, and calculated fee. * Creates TransactionBody with all required fields. @@ -684,6 +784,17 @@ export const assembleTransaction = ( yield* Effect.logWarning(`[Assembly] Could not find voter index for key: ${key}`) continue } + } else if (redeemerData.tag === "propose") { + // For propose redeemers, the key carries the proposal's position in proposalProcedures. + // Proposals keep insertion order, so the position is the redeemer index. + // Key format: `propose:{index}` + const proposalIndex = proposalKeyToIndex(key) + const proposalCount = state.proposalProcedures?.procedures.length ?? 0 + if (proposalIndex === undefined || proposalIndex >= proposalCount) { + yield* Effect.logWarning(`[Assembly] Could not find proposal index for key: ${key}`) + continue + } + redeemerIndex = proposalIndex } else { // For spend redeemers, look up in input index map redeemerIndex = inputIndexMap.get(key) @@ -699,7 +810,7 @@ export const assembleTransaction = ( // Create proper Redeemer object const redeemer = new Redeemer.Redeemer({ - tag: redeemerData.tag, // "spend", "mint", "cert", or "reward" + tag: redeemerData.tag, // "spend", "mint", "cert", "reward", "vote", or "propose" index: BigInt(redeemerIndex), // Use actual redeemer index data: redeemerData.data, exUnits: redeemerData.exUnits @@ -1206,7 +1317,7 @@ export const calculateFeeIteratively = ( redeemers: Map< string, { - readonly tag: "spend" | "mint" | "cert" | "reward" | "vote" + readonly tag: "spend" | "mint" | "cert" | "reward" | "vote" | "propose" readonly data: PlutusData.Data readonly exUnits?: { readonly mem: bigint; readonly steps: bigint } } diff --git a/packages/evolution/src/sdk/builders/operations/Operations.ts b/packages/evolution/src/sdk/builders/operations/Operations.ts index 39c6d13c..82eaaa20 100644 --- a/packages/evolution/src/sdk/builders/operations/Operations.ts +++ b/packages/evolution/src/sdk/builders/operations/Operations.ts @@ -523,6 +523,15 @@ export interface ProposeParams { readonly rewardAccount: RewardAccount.RewardAccount /** Optional anchor with metadata URL and hash */ readonly anchor: Anchor.Anchor | null + /** + * Redeemer for the constitution's guardrail script (propose purpose). + * Required when the governance action carries a Plutus `policyHash` + * (TreasuryWithdrawalsAction, ParameterChangeAction). The guardrail script + * itself is supplied via .attachScript() or a reference input. + */ + readonly redeemer?: RedeemerBuilder.RedeemerArg + /** Optional label for debugging script failures - identifies this operation in error messages */ + readonly label?: string } // ============================================================================ diff --git a/packages/evolution/src/sdk/builders/operations/Propose.ts b/packages/evolution/src/sdk/builders/operations/Propose.ts index 6dfcf7aa..bfe1950c 100644 --- a/packages/evolution/src/sdk/builders/operations/Propose.ts +++ b/packages/evolution/src/sdk/builders/operations/Propose.ts @@ -9,7 +9,14 @@ import { Effect, Ref } from "effect" import * as ProposalProcedure from "../../../ProposalProcedure.js" import * as ProposalProcedures from "../../../ProposalProcedures.js" -import { FullProtocolParametersTag, TransactionBuilderError, type TxBuilderConfigTag,TxContext } from "../TransactionBuilder.js" +import * as RedeemerBuilder from "../RedeemerBuilder.js" +import { + FullProtocolParametersTag, + proposalToKey, + TransactionBuilderError, + type TxBuilderConfigTag, + TxContext +} from "../TransactionBuilder.js" import type { ProposeParams } from "./Operations.js" /** @@ -20,7 +27,8 @@ import type { ProposeParams } from "./Operations.js" * 1. Fetches govActionDeposit from protocol parameters (like registerStake) * 2. Constructs ProposalProcedure with the fetched deposit * 3. Merges with existing proposal procedures if any - * 4. No redeemers needed - proposing is not script-controlled + * 4. Tracks the guardrail redeemer (propose purpose) if provided, keyed by the + * proposal's position in proposalProcedures * * Note: The deposit is deducted from transaction inputs during balancing. * @@ -52,6 +60,34 @@ export const createProposeProgram = ( // 3. Update state: merge proposal procedures yield* Ref.update(ctx, (state) => { let mergedProposalProcedures = state.proposalProcedures + const proposalIndex = mergedProposalProcedures?.procedures.length ?? 0 + + // Track guardrail redeemer for script-checked actions (propose purpose) + let newRedeemers = state.redeemers + let newDeferredRedeemers = state.deferredRedeemers + + if (params.redeemer) { + const deferred = RedeemerBuilder.toDeferredRedeemer(params.redeemer) + const proposalKey = proposalToKey(proposalIndex) + + if (deferred._tag === "static") { + newRedeemers = new Map(state.redeemers) + newRedeemers.set(proposalKey, { + tag: "propose", + data: deferred.data, + exUnits: undefined, + label: params.label + }) + } else { + newDeferredRedeemers = new Map(state.deferredRedeemers) + newDeferredRedeemers.set(proposalKey, { + tag: "propose", + deferred, + exUnits: undefined, + label: params.label + }) + } + } if (mergedProposalProcedures) { // Merge with existing proposals @@ -67,7 +103,9 @@ export const createProposeProgram = ( return { ...state, - proposalProcedures: mergedProposalProcedures + proposalProcedures: mergedProposalProcedures, + redeemers: newRedeemers, + deferredRedeemers: newDeferredRedeemers } }) diff --git a/packages/evolution/src/sdk/builders/phases/Evaluation.ts b/packages/evolution/src/sdk/builders/phases/Evaluation.ts index e3f09bdd..5f36f200 100644 --- a/packages/evolution/src/sdk/builders/phases/Evaluation.ts +++ b/packages/evolution/src/sdk/builders/phases/Evaluation.ts @@ -27,6 +27,7 @@ import { FullProtocolParametersTag, PhaseContextTag, type PhaseResult, + proposalToKey, type RedeemerData, type ScriptFailure, TransactionBuilderError, @@ -73,7 +74,8 @@ const enrichFailuresWithLabels = ( withdrawalIndexMapping: Map, mintIndexMapping: Map, certIndexMapping: Map, - voteIndexMapping: Map + voteIndexMapping: Map, + proposeIndexMapping: Map ): Array => { return failures.map((failure) => { const { index, purpose } = failure @@ -99,6 +101,8 @@ const enrichFailuresWithLabels = ( credential = redeemerKey?.replace("cert:", "") } else if (purpose === "vote") { redeemerKey = voteIndexMapping.get(index) + } else if (purpose === "propose") { + redeemerKey = proposeIndexMapping.get(index) } // Look up label from redeemer state @@ -452,6 +456,18 @@ export const executeEvaluation = (): Effect.Effect< } } + // Build proposal index mapping: index → "propose:{index}" + // Proposals keep insertion order, so only those with a redeemer need a mapping + const proposeIndexMapping = new Map() + const proposalCount = updatedState.proposalProcedures?.procedures.length ?? 0 + for (let i = 0; i < proposalCount; i++) { + const key = proposalToKey(i) + if (updatedState.redeemers.has(key)) { + proposeIndexMapping.set(i, key) + yield* Effect.logDebug(`[Evaluation] Proposal ${i} maps to: ${key}`) + } + } + const inputs = CoreUTxO.toInputs(sortedUtxos) const allOutputs = [...updatedState.outputs, ...buildCtx.changeOutputs] const transaction = yield* assembleTransaction(inputs, allOutputs, buildCtx.calculatedFee) @@ -508,7 +524,8 @@ export const executeEvaluation = (): Effect.Effect< withdrawalIndexMapping, mintIndexMapping, certIndexMapping, - voteIndexMapping + voteIndexMapping, + proposeIndexMapping ) // Create enhanced evaluation error with enriched failures @@ -703,6 +720,39 @@ export const executeEvaluation = (): Effect.Effect< }) ) } + } else if (evalRedeemer.redeemer_tag === "propose") { + // For propose redeemers, map index to proposal key + const proposalKey = proposeIndexMapping.get(evalRedeemer.redeemer_index) + if (!proposalKey) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: `Evaluator returned propose result at index ${evalRedeemer.redeemer_index} but no proposal redeemer exists at that position in the transaction` + }) + ) + } + + const redeemer = evaluatedRedeemers.get(proposalKey) + if (redeemer) { + // Update redeemer with ExUnits from evaluation + evaluatedRedeemers.set(proposalKey, { + ...redeemer, + exUnits: { + mem: BigInt(evalRedeemer.ex_units.mem), + steps: BigInt(evalRedeemer.ex_units.steps) + } + }) + + yield* Effect.logDebug( + `[Evaluation] Updated redeemer for ${proposalKey}: ` + + `mem=${evalRedeemer.ex_units.mem}, steps=${evalRedeemer.ex_units.steps}` + ) + } else { + return yield* Effect.fail( + new TransactionBuilderError({ + message: `Evaluator returned propose result for ${proposalKey} but no redeemer exists in builder state for that proposal` + }) + ) + } } else { // Unknown redeemer type returned by the evaluator — fail immediately. // Silently ignoring this would leave the redeemer at exUnits = 0, which diff --git a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts new file mode 100644 index 00000000..2fcdafea --- /dev/null +++ b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts @@ -0,0 +1,208 @@ +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 GovernanceAction from "../src/GovernanceAction.js" +import * as NativeScripts from "../src/NativeScripts.js" +import * as PlutusV3 from "../src/PlutusV3.js" +import * as Redeemer from "../src/Redeemer.js" +import * as RewardAccount from "../src/RewardAccount.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 { EvalRedeemer } from "../src/sdk/EvalRedeemer.js" +import type { ProtocolParameters } from "../src/sdk/provider/Provider.js" +import plutusJson from "./spec/plutus.json" +import { createCoreTestUtxo } from "./utils/utxo-helpers.js" + +const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" + +const FULL_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 baseConfig = { chain: mainnet } + +const guardrailScript = new PlutusV3.PlutusV3({ + bytes: Bytes.fromHex( + plutusJson.validators.find((v) => v.title === "governance_guardrail.always_yes_guardrail.propose")!.compiledCode + ) +}) +const guardrailHash = ScriptHash.fromScript(guardrailScript) + +const rewardAccount = new RewardAccount.RewardAccount({ + networkId: 1, + stakeCredential: new ScriptHash.ScriptHash({ hash: new Uint8Array(28).fill(0xab) }) +}) + +const treasuryWithdrawal = (policyHash: ScriptHash.ScriptHash | null) => + new GovernanceAction.TreasuryWithdrawalsAction({ + withdrawals: new Map([[rewardAccount, 1_000_000n]]), + policyHash + }) + +const EX_UNITS = { mem: 12_345n, steps: 6_789_000n } + +// Reports fixed ExUnits for every redeemer in the transaction, so the test can +// check that results are mapped back to the right proposal. +const makeEchoEvaluator = () => { + const seen: Array> = [] + const evaluator: Evaluator = { + evaluate: (tx) => + Effect.sync(() => { + const redeemers = tx.witnessSet.redeemers?.toArray() ?? [] + const results = redeemers.map((r) => ({ + redeemer_tag: r.tag, + redeemer_index: Number(r.index), + ex_units: new Redeemer.ExUnits(EX_UNITS) + })) + seen.push(results) + return results + }) + } + return { evaluator, seen } +} + +const makeUtxos = () => [ + createCoreTestUtxo({ transactionId: "a".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 200_000_000_000n }), + createCoreTestUtxo({ transactionId: "b".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 20_000_000n }) +] + +const buildOptions = (evaluator?: Evaluator) => ({ + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: makeUtxos(), + fullProtocolParameters: FULL_PROTOCOL_PARAMS, + ...(evaluator ? { evaluator } : {}) +}) + +describe("TxBuilder propose with guardrail script", () => { + it("adds a propose redeemer, the guardrail script and evaluated ExUnits", async () => { + const { evaluator, seen } = makeEchoEvaluator() + + const tx = await makeTxBuilder(baseConfig) + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount, + anchor: null, + redeemer: Data.constr(0n, []) + }) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + expect(seen.length).toBeGreaterThan(0) + const redeemers = tx.witnessSet.redeemers!.toArray() + expect(redeemers).toHaveLength(1) + expect(redeemers[0]!.tag).toBe("propose") + expect(redeemers[0]!.index).toBe(0n) + expect(redeemers[0]!.exUnits.mem).toBe(EX_UNITS.mem) + expect(redeemers[0]!.exUnits.steps).toBe(EX_UNITS.steps) + expect(tx.witnessSet.plutusV3Scripts?.length).toBe(1) + expect(tx.body.scriptDataHash).toBeDefined() + expect(tx.body.collateralInputs?.length ?? 0).toBeGreaterThan(0) + expect(tx.body.proposalProcedures?.procedures).toHaveLength(1) + }) + + it("indexes the redeemer by the proposal's position", async () => { + const { evaluator } = makeEchoEvaluator() + + const tx = await makeTxBuilder(baseConfig) + .propose({ governanceAction: new GovernanceAction.InfoAction({}), rewardAccount, anchor: null }) + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount, + anchor: null, + redeemer: Data.constr(0n, []), + label: "guardrail" + }) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + const procedures = tx.body.proposalProcedures!.procedures + expect(procedures).toHaveLength(2) + expect(procedures[1]!.governanceAction._tag).toBe("TreasuryWithdrawalsAction") + + const redeemers = tx.witnessSet.redeemers!.toArray() + expect(redeemers).toHaveLength(1) + expect(redeemers[0]!.tag).toBe("propose") + expect(redeemers[0]!.index).toBe(1n) + }) + + it("builds proposals without a policyHash as before (no redeemer, no script data)", async () => { + const tx = await makeTxBuilder(baseConfig) + .propose({ governanceAction: treasuryWithdrawal(null), rewardAccount, anchor: null }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.witnessSet.redeemers).toBeUndefined() + expect(tx.body.scriptDataHash).toBeUndefined() + }) + + it("rejects a guardrail-checked proposal without a redeemer", async () => { + await expect( + makeTxBuilder(baseConfig) + .propose({ governanceAction: treasuryWithdrawal(guardrailHash), rewardAccount, anchor: null }) + .attachScript({ script: guardrailScript }) + .build(buildOptions()) + ).rejects.toThrow(/[Rr]edeemer required/) + }) + + it("rejects a redeemer for a proposal that runs no guardrail script", async () => { + await expect( + makeTxBuilder(baseConfig) + .propose({ + governanceAction: new GovernanceAction.InfoAction({}), + rewardAccount, + anchor: null, + redeemer: Data.constr(0n, []) + }) + .build(buildOptions()) + ).rejects.toThrow(/without a guardrail policyHash/) + }) + + it("drops a redeemer supplied for a native-script guardrail", async () => { + const nativeGuardrail = NativeScripts.makeScriptNOfK(1n, [ + NativeScripts.makeScriptPubKey(new Uint8Array(28).fill(0xcc)).script + ]) + const nativeHash = ScriptHash.fromScript(nativeGuardrail) + + const tx = await makeTxBuilder(baseConfig) + .propose({ + governanceAction: treasuryWithdrawal(nativeHash), + rewardAccount, + anchor: null, + redeemer: Data.constr(0n, []) + }) + .attachScript({ script: nativeGuardrail }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.witnessSet.redeemers).toBeUndefined() + expect(tx.witnessSet.nativeScripts?.length ?? 0).toBeGreaterThan(0) + }) +}) diff --git a/packages/evolution/test/spec/plutus.json b/packages/evolution/test/spec/plutus.json index 2382d9d5..d3c5021c 100644 --- a/packages/evolution/test/spec/plutus.json +++ b/packages/evolution/test/spec/plutus.json @@ -36,6 +36,25 @@ "compiledCode": "585c01010029800aba2aba1aab9eaab9dab9a4888896600264653001300600198031803800cc0180092225980099b8748008c01cdd500144c8cc892898050009805180580098041baa0028a50401830060013003375400d149a26cac8009", "hash": "a2492486ed656e3fb854a2c240cf16055216c38ec94d166a533c5820" }, + { + "title": "governance_guardrail.always_yes_guardrail.propose", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/Data" + } + }, + "compiledCode": "585e01010029800aba2aba1aab9eaab9dab9a4888896600264653001300600198031803800cc0180092225980099b8748028c01cdd500144c8cc89289bad300a001300a300b0013008375400514a08030600c00260066ea801a29344d9590011", + "hash": "5cd92fa4900c10de05ba7a5072a7cb648e82c984ac538587aaeaf8d6" + }, + { + "title": "governance_guardrail.always_yes_guardrail.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "585e01010029800aba2aba1aab9eaab9dab9a4888896600264653001300600198031803800cc0180092225980099b8748028c01cdd500144c8cc89289bad300a001300a300b0013008375400514a08030600c00260066ea801a29344d9590011", + "hash": "5cd92fa4900c10de05ba7a5072a7cb648e82c984ac538587aaeaf8d6" + }, { "title": "governance_voting.always_yes_drep.vote", "redeemer": { diff --git a/packages/evolution/test/spec/validators/governance_guardrail.ak b/packages/evolution/test/spec/validators/governance_guardrail.ak new file mode 100644 index 00000000..0ebd26d6 --- /dev/null +++ b/packages/evolution/test/spec/validators/governance_guardrail.ak @@ -0,0 +1,15 @@ +use cardano/governance.{ProposalProcedure} +use cardano/transaction.{Transaction} + +/// Always-succeeding guardrail (constitution) script for testing proposals +/// that must run the constitution script, such as treasury withdrawals and +/// parameter changes. +validator always_yes_guardrail { + propose(_redeemer: Data, _proposal: ProposalProcedure, _self: Transaction) { + True + } + + else(_) { + False + } +} From 6c1c8f6612040bd2a6cba2906c3da88c09990e1a Mon Sep 17 00:00:00 2001 From: Tommy Date: Tue, 6 Oct 2026 16:34:58 +0200 Subject: [PATCH 2/4] fix(builders): reject self redeemers and missing guardrail scripts in propose() Review follow-ups: - A self redeemer has no input to resolve against for a proposal, so propose() now fails with a clear message instead of a resolution error. - A redeemer for a Plutus guardrail now requires the script to be attached or available from a reference or spent input, so a missing script fails at build time rather than at submission. - Unit tests cover propose next to mint redeemers with distinct ExUnits, two guardrail proposals, compose(), reference input scripts, batch redeemers, an unexpected evaluator index and label enrichment on script failure. - Docs note redeemer data and supported redeemer modes. --- .changeset/propose-guardrail-redeemer.md | 2 +- docs/content/docs/governance/proposals.mdx | 2 +- .../test/TxBuilder.ProposeGuardrail.test.ts | 2 +- .../src/sdk/builders/internal/txBuilder.ts | 24 +- .../src/sdk/builders/operations/Propose.ts | 14 +- .../test/TxBuilder.ProposeGuardrail.test.ts | 223 +++++++++++++++++- 6 files changed, 254 insertions(+), 13 deletions(-) diff --git a/.changeset/propose-guardrail-redeemer.md b/.changeset/propose-guardrail-redeemer.md index 6cf2d960..ae0e0e16 100644 --- a/.changeset/propose-guardrail-redeemer.md +++ b/.changeset/propose-guardrail-redeemer.md @@ -3,6 +3,6 @@ "@evolution-sdk/devnet": patch --- -`.propose()` can now run the constitution's guardrail script. `ProposeParams` takes an optional `redeemer` (and `label`), which the builder adds under the propose purpose at the proposal's index, evaluates and balances like any other script redeemer. Provide the guardrail script with `.attachScript()` or `.readFrom()`. This makes `TreasuryWithdrawalsAction` and `ParameterChangeAction` submittable on networks whose constitution has a guardrail, such as mainnet. The build now fails early when a proposal with a Plutus `policyHash` has no redeemer, or when a redeemer is given for a proposal that runs no guardrail. +`.propose()` can now run the constitution's guardrail script. `ProposeParams` takes an optional `redeemer` (and `label`), which the builder adds under the propose purpose at the proposal's index, evaluates and balances like any other script redeemer. Provide the guardrail script with `.attachScript()` or `.readFrom()`. This makes `TreasuryWithdrawalsAction` and `ParameterChangeAction` submittable on networks whose constitution has a guardrail, such as mainnet. The build now fails early when a proposal with a Plutus `policyHash` has no redeemer or no guardrail script, when a redeemer is given for a proposal that runs no guardrail, or when a self redeemer is passed. The devnet `ConwayGenesis` type accepts an optional `constitution.script` guardrail hash. diff --git a/docs/content/docs/governance/proposals.mdx b/docs/content/docs/governance/proposals.mdx index 95309ac2..d0c3385f 100644 --- a/docs/content/docs/governance/proposals.mdx +++ b/docs/content/docs/governance/proposals.mdx @@ -116,7 +116,7 @@ const signed = await tx.sign() await signed.submit() ``` -The redeemer is placed at the proposal's index in the transaction, so guardrail-checked proposals can be combined with other proposals in one transaction. The build fails if a proposal with a Plutus `policyHash` has no redeemer. +The redeemer is placed at the proposal's index in the transaction, so guardrail-checked proposals can be combined with other proposals in one transaction. Its data must be what the guardrail script expects; the mainnet guardrail does not inspect it. Static and batch redeemers are supported; self redeemers are not, since a proposal spends no input. The build fails if a proposal with a Plutus `policyHash` has no redeemer, or if its guardrail script is neither attached nor available from a reference input. ## Next Steps diff --git a/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts index 93d617d7..b5927c20 100644 --- a/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts @@ -130,7 +130,7 @@ describe("TxBuilder propose with guardrail script", () => { .build() .then((b) => b.sign()) .then((b) => b.submit()) - ).rejects.toThrow(/guardrails hash/) + ).rejects.toThrow(/guardrail/i) }) it("submits a treasury withdrawal with an attached guardrail script", { timeout: 120_000 }, async () => { diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index c865fa3b..ce68fb9d 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -501,8 +501,15 @@ export const validateProposalRedeemers: Effect.Effect + [...state.referenceInputs, ...state.selectedUtxos].some( + (utxo) => utxo.scriptRef !== undefined && ScriptHash.toHex(ScriptHash.fromScript(utxo.scriptRef)) === scriptHashHex + ) + const proposalsMissingRedeemer: Array = [] const proposalsWithoutPolicy: Array = [] + const proposalsMissingScript: Array = [] const nativeProposalRedeemerKeys: Array = [] state.proposalProcedures.procedures.forEach((procedure, index) => { @@ -515,10 +522,13 @@ export const validateProposalRedeemers: Effect.Effect 0) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + `Guardrail script not provided for ${proposalsMissingScript.length} proposal(s): ` + + `${proposalsMissingScript.join(", ")}. ` + + `Attach it via .attachScript() or provide it through a reference input with .readFrom().`, + cause: proposalsMissingScript + }) + ) + } + if (proposalsWithoutPolicy.length > 0) { return yield* Effect.fail( new TransactionBuilderError({ diff --git a/packages/evolution/src/sdk/builders/operations/Propose.ts b/packages/evolution/src/sdk/builders/operations/Propose.ts index bfe1950c..5db54640 100644 --- a/packages/evolution/src/sdk/builders/operations/Propose.ts +++ b/packages/evolution/src/sdk/builders/operations/Propose.ts @@ -49,6 +49,17 @@ export const createProposeProgram = ( } const govActionDeposit = fullParams.govActionDeposit + const deferred = params.redeemer ? RedeemerBuilder.toDeferredRedeemer(params.redeemer) : undefined + if (deferred?._tag === "self") { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + "Self redeemers are not supported for proposals: a proposal spends no input. " + + "Pass static redeemer data or a batch redeemer instead." + }) + ) + } + // 2. Construct ProposalProcedure with fetched deposit const proposalProcedure = new ProposalProcedure.ProposalProcedure({ deposit: govActionDeposit, @@ -66,8 +77,7 @@ export const createProposeProgram = ( let newRedeemers = state.redeemers let newDeferredRedeemers = state.deferredRedeemers - if (params.redeemer) { - const deferred = RedeemerBuilder.toDeferredRedeemer(params.redeemer) + if (deferred) { const proposalKey = proposalToKey(proposalIndex) if (deferred._tag === "static") { diff --git a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts index 2fcdafea..0a9650fb 100644 --- a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts +++ b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts @@ -2,19 +2,24 @@ 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 GovernanceAction from "../src/GovernanceAction.js" import * as NativeScripts from "../src/NativeScripts.js" import * as PlutusV3 from "../src/PlutusV3.js" +import * as ProtocolParamUpdate from "../src/ProtocolParamUpdate.js" import * as Redeemer from "../src/Redeemer.js" import * as RewardAccount from "../src/RewardAccount.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 { EvaluationError, makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" import { mainnet } from "../src/sdk/client/index.js" import type { EvalRedeemer } from "../src/sdk/EvalRedeemer.js" import type { ProtocolParameters } from "../src/sdk/provider/Provider.js" +import * as Text from "../src/Text.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as CoreUTxO from "../src/UTxO.js" import plutusJson from "./spec/plutus.json" import { createCoreTestUtxo } from "./utils/utxo-helpers.js" @@ -65,10 +70,16 @@ const treasuryWithdrawal = (policyHash: ScriptHash.ScriptHash | null) => policyHash }) -const EX_UNITS = { mem: 12_345n, steps: 6_789_000n } +const TAG_OFFSET: Record = { spend: 1n, mint: 2n, cert: 3n, reward: 4n, vote: 5n, propose: 6n } -// Reports fixed ExUnits for every redeemer in the transaction, so the test can -// check that results are mapped back to the right proposal. +// Distinct ExUnits per (tag, index), so tests can tell whether each result was +// mapped back to the right redeemer. +const unitsFor = (tag: string, index: number) => ({ + mem: 10_000n + TAG_OFFSET[tag]! * 1_000n + BigInt(index), + steps: 5_000_000n + TAG_OFFSET[tag]! * 100_000n + BigInt(index) +}) + +// Reports ExUnits for every redeemer in the transaction const makeEchoEvaluator = () => { const seen: Array> = [] const evaluator: Evaluator = { @@ -78,7 +89,7 @@ const makeEchoEvaluator = () => { const results = redeemers.map((r) => ({ redeemer_tag: r.tag, redeemer_index: Number(r.index), - ex_units: new Redeemer.ExUnits(EX_UNITS) + ex_units: new Redeemer.ExUnits(unitsFor(r.tag, Number(r.index))) })) seen.push(results) return results @@ -119,8 +130,8 @@ describe("TxBuilder propose with guardrail script", () => { expect(redeemers).toHaveLength(1) expect(redeemers[0]!.tag).toBe("propose") expect(redeemers[0]!.index).toBe(0n) - expect(redeemers[0]!.exUnits.mem).toBe(EX_UNITS.mem) - expect(redeemers[0]!.exUnits.steps).toBe(EX_UNITS.steps) + expect(redeemers[0]!.exUnits.mem).toBe(unitsFor("propose", 0).mem) + expect(redeemers[0]!.exUnits.steps).toBe(unitsFor("propose", 0).steps) expect(tx.witnessSet.plutusV3Scripts?.length).toBe(1) expect(tx.body.scriptDataHash).toBeDefined() expect(tx.body.collateralInputs?.length ?? 0).toBeGreaterThan(0) @@ -206,3 +217,201 @@ describe("TxBuilder propose with guardrail script", () => { expect(tx.witnessSet.nativeScripts?.length ?? 0).toBeGreaterThan(0) }) }) + +const loadScript = (title: string) => + new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(plutusJson.validators.find((v) => v.title === title)!.compiledCode) }) + +const parameterChange = () => + new GovernanceAction.ParameterChangeAction({ + govActionId: null, + protocolParamUpdate: new ProtocolParamUpdate.ProtocolParamUpdate({ maxTxSize: 16_385n }), + policyHash: guardrailHash + }) + +const guardedProposal = (governanceAction: GovernanceAction.GovernanceAction, label?: string) => ({ + governanceAction, + rewardAccount, + anchor: null, + redeemer: Data.constr(0n, []), + ...(label ? { label } : {}) +}) + +// Walk an error's cause chain and return the first enriched failures list +const findFailures = (error: unknown): ReadonlyArray<{ purpose: string; index: number; label?: string }> | undefined => { + let current: any = error + for (let depth = 0; current && depth < 10; depth++) { + if (Array.isArray(current.failures)) return current.failures + current = current.cause + } + return undefined +} + +describe("TxBuilder propose with guardrail script: combinations", () => { + it("keeps propose and mint redeemers apart in one transaction", async () => { + const { evaluator } = makeEchoEvaluator() + const mintScript = loadScript("simple_mint.simple_mint.mint") + const unit = ScriptHash.toHex(ScriptHash.fromScript(mintScript)) + Text.toHex("Token") + + const tx = await makeTxBuilder(baseConfig) + .mintAssets({ assets: CoreAssets.fromRecord({ [unit]: 1n }), redeemer: Data.constr(0n, []) }) + .attachScript({ script: mintScript }) + .propose(guardedProposal(treasuryWithdrawal(guardrailHash))) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + const redeemers = tx.witnessSet.redeemers!.toArray() + expect(redeemers.map((r) => `${r.tag}#${r.index}`).sort()).toEqual(["mint#0", "propose#0"]) + for (const r of redeemers) { + expect(r.exUnits.mem).toBe(unitsFor(r.tag, Number(r.index)).mem) + expect(r.exUnits.steps).toBe(unitsFor(r.tag, Number(r.index)).steps) + } + expect(tx.witnessSet.plutusV3Scripts?.length).toBe(2) + }) + + it("evaluates two guardrail-checked proposals at indices 0 and 1", async () => { + const { evaluator } = makeEchoEvaluator() + + const tx = await makeTxBuilder(baseConfig) + .propose(guardedProposal(treasuryWithdrawal(guardrailHash))) + .propose(guardedProposal(parameterChange())) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + const procedures = tx.body.proposalProcedures!.procedures + expect(procedures.map((p) => p.governanceAction._tag)).toEqual(["TreasuryWithdrawalsAction", "ParameterChangeAction"]) + const redeemers = tx.witnessSet.redeemers!.toArray() + expect(redeemers.map((r) => `${r.tag}#${r.index}`).sort()).toEqual(["propose#0", "propose#1"]) + for (const r of redeemers) expect(r.exUnits.mem).toBe(unitsFor("propose", Number(r.index)).mem) + }) + + it("indexes a proposal added through .compose() by its final position", async () => { + const { evaluator } = makeEchoEvaluator() + const guarded = makeTxBuilder(baseConfig) + .propose(guardedProposal(treasuryWithdrawal(guardrailHash))) + .attachScript({ script: guardrailScript }) + + const tx = await makeTxBuilder(baseConfig) + .propose({ governanceAction: new GovernanceAction.InfoAction({}), rewardAccount, anchor: null }) + .compose(guarded) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + const procedures = tx.body.proposalProcedures!.procedures + const guardedIndex = procedures.findIndex((p) => p.governanceAction._tag === "TreasuryWithdrawalsAction") + const redeemers = tx.witnessSet.redeemers!.toArray() + expect(redeemers).toHaveLength(1) + expect(redeemers[0]!.index).toBe(BigInt(guardedIndex)) + }) + + it("takes the guardrail script from a reference input", async () => { + const { evaluator } = makeEchoEvaluator() + const refUtxo = new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex("c".repeat(64)), + index: 0n, + address: CoreAddress.fromBech32(CHANGE_ADDRESS), + assets: CoreAssets.fromLovelace(20_000_000n), + scriptRef: guardrailScript + }) + + const tx = await makeTxBuilder(baseConfig) + .propose(guardedProposal(treasuryWithdrawal(guardrailHash))) + .readFrom({ referenceInputs: [refUtxo] }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + expect(tx.witnessSet.plutusV3Scripts?.length ?? 0).toBe(0) + expect(tx.body.referenceInputs?.length).toBe(1) + expect(tx.witnessSet.redeemers!.toArray()[0]!.tag).toBe("propose") + }) + + it("accepts a batch redeemer built from collected inputs", async () => { + const { evaluator } = makeEchoEvaluator() + const [fundingUtxo] = makeUtxos() + + const tx = await makeTxBuilder(baseConfig) + .collectFrom({ inputs: [fundingUtxo!] }) + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount, + anchor: null, + redeemer: { all: (inputs) => Data.constr(0n, [Data.int(BigInt(inputs[0]!.index))]), inputs: [fundingUtxo!] } + }) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + const [redeemer] = tx.witnessSet.redeemers!.toArray() + expect(redeemer!.tag).toBe("propose") + expect(redeemer!.index).toBe(0n) + expect(redeemer!.data).toEqual(Data.constr(0n, [Data.int(0n)])) + }) +}) + +describe("TxBuilder propose with guardrail script: errors", () => { + it("rejects a self redeemer", async () => { + await expect( + makeTxBuilder(baseConfig) + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount, + anchor: null, + redeemer: () => Data.constr(0n, []) + }) + .attachScript({ script: guardrailScript }) + .build(buildOptions()) + ).rejects.toThrow(/Self redeemers are not supported for proposals/) + }) + + it("rejects a redeemer when the guardrail script is not provided", async () => { + await expect( + makeTxBuilder(baseConfig) + .propose(guardedProposal(treasuryWithdrawal(guardrailHash))) + .build(buildOptions()) + ).rejects.toThrow(/Guardrail script not provided/) + }) + + it("fails when the evaluator reports a propose index without a redeemer", async () => { + const evaluator: Evaluator = { + evaluate: () => + Effect.succeed([ + { redeemer_tag: "propose", redeemer_index: 0, ex_units: new Redeemer.ExUnits(unitsFor("propose", 0)) }, + { redeemer_tag: "propose", redeemer_index: 5, ex_units: new Redeemer.ExUnits(unitsFor("propose", 5)) } + ]) + } + + await expect( + makeTxBuilder(baseConfig) + .propose(guardedProposal(treasuryWithdrawal(guardrailHash))) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + ).rejects.toThrow(/propose result at index 5/) + }) + + it("labels a failing guardrail in the evaluation error", async () => { + const evaluator: Evaluator = { + evaluate: () => + Effect.fail( + new EvaluationError({ + message: "guardrail rejected", + failures: [{ purpose: "propose", index: 1, validationError: "rejected", traces: [] }] + }) + ) + } + + const error = await makeTxBuilder(baseConfig) + .propose({ governanceAction: new GovernanceAction.InfoAction({}), rewardAccount, anchor: null }) + .propose(guardedProposal(parameterChange(), "params-guardrail")) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then( + () => undefined, + (e: unknown) => e + ) + + const failures = findFailures(error) + expect(failures?.[0]?.purpose).toBe("propose") + expect(failures?.[0]?.label).toBe("params-guardrail") + }) +}) From d9f63b67fd7a6b78c01d72035af4d61167129c35 Mon Sep 17 00:00:00 2001 From: Tommy Date: Tue, 6 Oct 2026 17:19:48 +0200 Subject: [PATCH 3/4] fix(builders): accept redeemer 0 and native guardrails from spent inputs in propose() --- .../src/sdk/builders/internal/txBuilder.ts | 10 ++-- .../src/sdk/builders/operations/Propose.ts | 3 +- .../test/TxBuilder.ProposeGuardrail.test.ts | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index ce68fb9d..8e243853 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -311,15 +311,19 @@ const certScriptHashHex = (certificate: Certificate.Certificate): string | undef /** * Build a resolver that reports whether the script behind a given hash is a native * script. Looks at scripts attached via `.attachScript()` first, then any reference - * inputs carrying a script. Returns `undefined` when the script cannot be classified. + * or spent inputs carrying a script. Returns `undefined` when the script cannot be classified. */ const makeIsNativeScript = - (state: { scripts: ReadonlyMap; referenceInputs: ReadonlyArray }) => + (state: { + scripts: ReadonlyMap + referenceInputs: ReadonlyArray + selectedUtxos: ReadonlyArray + }) => (scriptHashHex: string): boolean | undefined => { const attached = state.scripts.get(scriptHashHex) if (attached) return attached._tag === "NativeScript" - for (const ref of state.referenceInputs) { + for (const ref of [...state.referenceInputs, ...state.selectedUtxos]) { if (ref.scriptRef && ScriptHash.toHex(ScriptHash.fromScript(ref.scriptRef)) === scriptHashHex) { return ref.scriptRef._tag === "NativeScript" } diff --git a/packages/evolution/src/sdk/builders/operations/Propose.ts b/packages/evolution/src/sdk/builders/operations/Propose.ts index 5db54640..a099de18 100644 --- a/packages/evolution/src/sdk/builders/operations/Propose.ts +++ b/packages/evolution/src/sdk/builders/operations/Propose.ts @@ -49,7 +49,8 @@ export const createProposeProgram = ( } const govActionDeposit = fullParams.govActionDeposit - const deferred = params.redeemer ? RedeemerBuilder.toDeferredRedeemer(params.redeemer) : undefined + // A redeemer may be the integer 0, so test for presence rather than truthiness + const deferred = params.redeemer !== undefined ? RedeemerBuilder.toDeferredRedeemer(params.redeemer) : undefined if (deferred?._tag === "self") { return yield* Effect.fail( new TransactionBuilderError({ diff --git a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts index 0a9650fb..62b95816 100644 --- a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts +++ b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts @@ -347,6 +347,59 @@ describe("TxBuilder propose with guardrail script: combinations", () => { expect(redeemer!.index).toBe(0n) expect(redeemer!.data).toEqual(Data.constr(0n, [Data.int(0n)])) }) + + it("accepts the integer redeemer 0", async () => { + const { evaluator } = makeEchoEvaluator() + + const tx = await makeTxBuilder(baseConfig) + .propose({ + governanceAction: treasuryWithdrawal(guardrailHash), + rewardAccount, + anchor: null, + redeemer: Data.int(0n) + }) + .attachScript({ script: guardrailScript }) + .build(buildOptions(evaluator)) + .then((b) => b.toTransaction()) + + const [redeemer] = tx.witnessSet.redeemers!.toArray() + expect(redeemer!.tag).toBe("propose") + expect(redeemer!.data).toBe(0n) + }) + + describe("native guardrail carried by a spent input", () => { + const nativeGuardrail = NativeScripts.makeScriptNOfK(1n, [ + NativeScripts.makeScriptPubKey(new Uint8Array(28).fill(0xcc)).script + ]) + const scriptUtxo = new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex("d".repeat(64)), + index: 0n, + address: CoreAddress.fromBech32(CHANGE_ADDRESS), + assets: CoreAssets.fromLovelace(20_000_000n), + scriptRef: nativeGuardrail + }) + const nativeAction = () => treasuryWithdrawal(ScriptHash.fromScript(nativeGuardrail)) + + it("needs no redeemer", async () => { + const tx = await makeTxBuilder(baseConfig) + .collectFrom({ inputs: [scriptUtxo] }) + .propose({ governanceAction: nativeAction(), rewardAccount, anchor: null }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.witnessSet.redeemers).toBeUndefined() + }) + + it("drops a supplied redeemer", async () => { + const tx = await makeTxBuilder(baseConfig) + .collectFrom({ inputs: [scriptUtxo] }) + .propose({ governanceAction: nativeAction(), rewardAccount, anchor: null, redeemer: Data.constr(0n, []) }) + .build(buildOptions()) + .then((b) => b.toTransaction()) + + expect(tx.witnessSet.redeemers).toBeUndefined() + }) + }) }) describe("TxBuilder propose with guardrail script: errors", () => { From 96e3f246ac8d14a5d687cc8900af9123a69b890c Mon Sep 17 00:00:00 2001 From: Tommy Date: Tue, 6 Oct 2026 17:48:42 +0200 Subject: [PATCH 4/4] fix(builders): count native script signers from spent inputs and tighten propose() docs --- .changeset/propose-guardrail-redeemer.md | 2 ++ docs/content/docs/governance/proposals.mdx | 4 +-- .../src/sdk/builders/TransactionBuilder.ts | 22 ++++++++++++ .../src/sdk/builders/internal/txBuilder.ts | 29 +++++++--------- .../test/TxBuilder.NativeScriptVote.test.ts | 34 +++++++++++++++++++ .../test/TxBuilder.ProposeGuardrail.test.ts | 25 +++++++++----- 6 files changed, 89 insertions(+), 27 deletions(-) diff --git a/.changeset/propose-guardrail-redeemer.md b/.changeset/propose-guardrail-redeemer.md index ae0e0e16..f0e958b5 100644 --- a/.changeset/propose-guardrail-redeemer.md +++ b/.changeset/propose-guardrail-redeemer.md @@ -5,4 +5,6 @@ `.propose()` can now run the constitution's guardrail script. `ProposeParams` takes an optional `redeemer` (and `label`), which the builder adds under the propose purpose at the proposal's index, evaluates and balances like any other script redeemer. Provide the guardrail script with `.attachScript()` or `.readFrom()`. This makes `TreasuryWithdrawalsAction` and `ParameterChangeAction` submittable on networks whose constitution has a guardrail, such as mainnet. The build now fails early when a proposal with a Plutus `policyHash` has no redeemer or no guardrail script, when a redeemer is given for a proposal that runs no guardrail, or when a self redeemer is passed. +Native scripts carried by a spent input are now recognized for voters, certificates and proposals, and their signers are counted in the fee estimate. + The devnet `ConwayGenesis` type accepts an optional `constitution.script` guardrail hash. diff --git a/docs/content/docs/governance/proposals.mdx b/docs/content/docs/governance/proposals.mdx index d0c3385f..3463e640 100644 --- a/docs/content/docs/governance/proposals.mdx +++ b/docs/content/docs/governance/proposals.mdx @@ -85,7 +85,7 @@ await signed.submit() ## Guardrail Script -When the constitution defines a guardrail script (as on mainnet), `ParameterChangeAction` and `TreasuryWithdrawalsAction` must reference it in `policyHash` and run it under the propose purpose. Pass a `redeemer` to `.propose()` and provide the script with `.attachScript()` or a reference input via `.readFrom()`: +When the constitution defines a guardrail script (as on mainnet), `ParameterChangeAction` and `TreasuryWithdrawalsAction` must reference it in `policyHash` and run it under the propose purpose. For a Plutus guardrail, pass a `redeemer` to `.propose()` and provide the script with `.attachScript()`, a reference input via `.readFrom()`, or a spent input that carries it: ```typescript twoslash import { Anchor, Data, GovernanceAction, PlutusV3, RewardAccount, ScriptHash, preprod, Client } from "@evolution-sdk/evolution" @@ -116,7 +116,7 @@ const signed = await tx.sign() await signed.submit() ``` -The redeemer is placed at the proposal's index in the transaction, so guardrail-checked proposals can be combined with other proposals in one transaction. Its data must be what the guardrail script expects; the mainnet guardrail does not inspect it. Static and batch redeemers are supported; self redeemers are not, since a proposal spends no input. The build fails if a proposal with a Plutus `policyHash` has no redeemer, or if its guardrail script is neither attached nor available from a reference input. +The redeemer is placed at the proposal's index in the transaction, so guardrail-checked proposals can be combined with other proposals in one transaction. Its data must be what the guardrail script expects. The mainnet guardrail does not inspect it. Static and batch redeemers are supported. Self redeemers are not, since a proposal spends no input. A native-script guardrail needs no redeemer, and one passed for it is dropped. The build fails if a proposal with a Plutus `policyHash` has no redeemer, or if its guardrail script is neither attached nor carried by a reference or spent input. ## Next Steps diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 59eacf84..9b40ac0f 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -1400,6 +1400,10 @@ export interface TransactionBuilderBase { * Call .propose() multiple times to submit multiple proposals in one transaction. * Consistent with .registerStake() and .registerDRep() - no manual deposit handling. * + * ParameterChangeAction and TreasuryWithdrawalsAction with a Plutus `policyHash` run the + * constitution's guardrail script. Pass a `redeemer` and provide the script via + * .attachScript(), .readFrom() or a spent input that carries it. + * * The deposit amount is automatically deducted during transaction balancing. * * Queues a deferred operation that will be executed when build() is called. @@ -1407,8 +1411,10 @@ export interface TransactionBuilderBase { * * @example * ```typescript + * import * as Data from "@evolution-sdk/Data" * import * as GovernanceAction from "@evolution-sdk/GovernanceAction" * import * as RewardAccount from "@evolution-sdk/RewardAccount" + * import * as ScriptHash from "@evolution-sdk/ScriptHash" * * // Submit single proposal (deposit auto-fetched) * await client.newTx() @@ -1436,6 +1442,22 @@ export interface TransactionBuilderBase { * .build() * .then(tx => tx.sign()) * .then(tx => tx.submit()) + * + * // Treasury withdrawal checked by the constitution's guardrail script + * await client.newTx() + * .propose({ + * governanceAction: new GovernanceAction.TreasuryWithdrawalsAction({ + * withdrawals: new Map([[myRewardAccount, 1_000_000_000n]]), + * policyHash: ScriptHash.fromScript(guardrailScript) + * }), + * rewardAccount: myRewardAccount, + * anchor: myAnchor, + * redeemer: Data.constr(0n, []) + * }) + * .attachScript({ script: guardrailScript }) + * .build() + * .then(tx => tx.sign()) + * .then(tx => tx.submit()) * ``` * * @since 2.0.0 diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index 8e243853..0ed060df 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -375,7 +375,7 @@ export const validateVoterRedeemers: Effect.Effect - [...state.referenceInputs, ...state.selectedUtxos].some( - (utxo) => utxo.scriptRef !== undefined && ScriptHash.toHex(ScriptHash.fromScript(utxo.scriptRef)) === scriptHashHex - ) - const proposalsMissingRedeemer: Array = [] const proposalsWithoutPolicy: Array = [] const proposalsMissingScript: Array = [] @@ -531,7 +525,7 @@ export const validateProposalRedeemers: Effect.Effect { expect(tx.body.referenceInputs?.length ?? 0).toBeGreaterThan(0) }) + it("classifies a native voter whose script is carried by a spent input", async () => { + const { script, voter } = makeMultisigDRep() + const votingProcedures = VotingProcedures.singleVote(voter, govActionId, yesProcedure) + const walletUtxo = createCoreTestUtxo({ + transactionId: "a".repeat(64), + index: 0n, + address: CHANGE_ADDRESS, + lovelace: 100_000_000n + }) + const scriptUtxo = new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex("b".repeat(64)), + index: 0n, + address: CoreAddress.fromBech32(CHANGE_ADDRESS), + assets: CoreAssets.fromLovelace(5_000_000n), + scriptRef: script + }) + + const signBuilder = await makeTxBuilder(baseConfig) + .collectFrom({ inputs: [scriptUtxo] }) + .vote({ votingProcedures }) + .build({ + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: [walletUtxo], + protocolParameters: PROTOCOL_PARAMS + }) + + const tx = await signBuilder.toTransaction() + expect(tx.witnessSet.redeemers).toBeUndefined() + + // 2-of-3 threshold signers on top of the wallet-input signer + const fakeTx = await signBuilder.toTransactionWithFakeWitnesses() + expect(fakeTx.witnessSet.vkeyWitnesses?.length ?? 0).toBeGreaterThanOrEqual(3) + }) + it("allows a native-script Constitutional Committee voter without a redeemer", async () => { const { script, scriptHash } = makeMultisigDRep() const voter = new VotingProcedures.ConstitutionalCommitteeVoter({ credential: scriptHash }) diff --git a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts index 62b95816..27ce161e 100644 --- a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts +++ b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts @@ -12,7 +12,7 @@ import * as ProtocolParamUpdate from "../src/ProtocolParamUpdate.js" import * as Redeemer from "../src/Redeemer.js" import * as RewardAccount from "../src/RewardAccount.js" import * as ScriptHash from "../src/ScriptHash.js" -import type { Evaluator } from "../src/sdk/builders/TransactionBuilder.js" +import type { Evaluator, ScriptFailure } from "../src/sdk/builders/TransactionBuilder.js" import { EvaluationError, makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" import { mainnet } from "../src/sdk/client/index.js" import type { EvalRedeemer } from "../src/sdk/EvalRedeemer.js" @@ -237,11 +237,14 @@ const guardedProposal = (governanceAction: GovernanceAction.GovernanceAction, la }) // Walk an error's cause chain and return the first enriched failures list -const findFailures = (error: unknown): ReadonlyArray<{ purpose: string; index: number; label?: string }> | undefined => { - let current: any = error - for (let depth = 0; current && depth < 10; depth++) { - if (Array.isArray(current.failures)) return current.failures - current = current.cause +type ErrorLike = { failures?: ReadonlyArray; cause?: unknown } + +const findFailures = (error: unknown): ReadonlyArray | undefined => { + let current: unknown = error + for (let depth = 0; typeof current === "object" && current !== null && depth < 10; depth++) { + const { cause, failures } = current as ErrorLike + if (Array.isArray(failures)) return failures + current = cause } return undefined } @@ -380,14 +383,18 @@ describe("TxBuilder propose with guardrail script: combinations", () => { }) const nativeAction = () => treasuryWithdrawal(ScriptHash.fromScript(nativeGuardrail)) - it("needs no redeemer", async () => { - const tx = await makeTxBuilder(baseConfig) + it("needs no redeemer and sizes the fee for the script's signer", async () => { + const signBuilder = await makeTxBuilder(baseConfig) .collectFrom({ inputs: [scriptUtxo] }) .propose({ governanceAction: nativeAction(), rewardAccount, anchor: null }) .build(buildOptions()) - .then((b) => b.toTransaction()) + const tx = await signBuilder.toTransaction() expect(tx.witnessSet.redeemers).toBeUndefined() + + // The wallet key and the guardrail's own signer both need a vkey witness + const fakeTx = await signBuilder.toTransactionWithFakeWitnesses() + expect(fakeTx.witnessSet.vkeyWitnesses?.length ?? 0).toBeGreaterThanOrEqual(2) }) it("drops a supplied redeemer", async () => {