diff --git a/.changeset/propose-guardrail-redeemer.md b/.changeset/propose-guardrail-redeemer.md new file mode 100644 index 00000000..f0e958b5 --- /dev/null +++ b/.changeset/propose-guardrail-redeemer.md @@ -0,0 +1,10 @@ +--- +"@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 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 e99a312f..3463e640 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. 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" + +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. 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 - [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..b5927c20 --- /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(/guardrail/i) + }) + + 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..9b40ac0f 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. * @@ -1371,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. @@ -1378,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() @@ -1407,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/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..0ed060df 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, @@ -308,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" } @@ -368,7 +375,7 @@ export const validateVoterRedeemers: 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 proposalsMissingScript: 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 + } + + const native = isNativeScript(scriptHashHex) + if (native === true) { + if (hasRedeemer) nativeProposalRedeemerKeys.push(proposalKey) + } else if (!hasRedeemer) { + proposalsMissingRedeemer.push(`${proposalKey} (policy ${scriptHashHex})`) + } else if (native === undefined) { + proposalsMissingScript.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(", ")}. ` + + `If the guardrail is a native (multisig) script, attach it via .attachScript() ` + + `(or provide it through a reference or spent input) so it is recognized and no redeemer is needed. ` + + `If it is a Plutus script, pass a redeemer to .propose() and provide the script the same way.`, + cause: proposalsMissingRedeemer + }) + ) + } + + if (proposalsMissingScript.length > 0) { + return yield* Effect.fail( + new TransactionBuilderError({ + message: + `Guardrail script not provided for ${proposalsMissingScript.length} proposal(s): ` + + `${proposalsMissingScript.join(", ")}. ` + + `Attach it via .attachScript(), provide it through a reference input with .readFrom(), ` + + `or spend an input that carries it.`, + cause: proposalsMissingScript + }) + ) + } + + 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 +807,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 +833,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 @@ -1094,11 +1228,11 @@ export const buildFakeWitnessSet = ( } } - // Also count required signers from reference scripts (scripts in referenceInputs) - for (const refUtxo of state.referenceInputs) { - if (refUtxo.scriptRef && refUtxo.scriptRef._tag === "NativeScript") { - const requiredSigners = addNativeScriptWitnesses(refUtxo.scriptRef) - yield* Effect.logDebug(`[buildFakeWitnessSet] Reference native script requires ${requiredSigners} signers`) + // Also count required signers from scripts carried by reference or spent inputs + for (const utxo of [...state.referenceInputs, ...inputUtxos]) { + if (utxo.scriptRef && utxo.scriptRef._tag === "NativeScript") { + const requiredSigners = addNativeScriptWitnesses(utxo.scriptRef) + yield* Effect.logDebug(`[buildFakeWitnessSet] Input-carried native script requires ${requiredSigners} signers`) } } @@ -1206,7 +1340,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..a099de18 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. * @@ -41,6 +49,18 @@ export const createProposeProgram = ( } const govActionDeposit = fullParams.govActionDeposit + // 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({ + 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, @@ -52,6 +72,33 @@ 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 (deferred) { + 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 +114,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.NativeScriptVote.test.ts b/packages/evolution/test/TxBuilder.NativeScriptVote.test.ts index 5265ccb3..0ecfeb56 100644 --- a/packages/evolution/test/TxBuilder.NativeScriptVote.test.ts +++ b/packages/evolution/test/TxBuilder.NativeScriptVote.test.ts @@ -223,6 +223,40 @@ describe("TxBuilder NativeScript Vote (native-script DRep)", () => { 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 new file mode 100644 index 00000000..27ce161e --- /dev/null +++ b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts @@ -0,0 +1,477 @@ +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, 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" +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" + +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 TAG_OFFSET: Record = { spend: 1n, mint: 2n, cert: 3n, reward: 4n, vote: 5n, propose: 6n } + +// 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 = { + 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(unitsFor(r.tag, Number(r.index))) + })) + 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(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) + 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) + }) +}) + +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 +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 +} + +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)])) + }) + + 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 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()) + + 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 () => { + 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", () => { + 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") + }) +}) 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 + } +}