Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/propose-guardrail-redeemer.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions docs/content/docs/governance/proposals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/evolution-devnet/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
Expand Down
226 changes: 226 additions & 0 deletions packages/evolution-devnet/test/TxBuilder.ProposeGuardrail.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
55 changes: 53 additions & 2 deletions packages/evolution/src/sdk/builders/TransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -1371,15 +1400,21 @@ 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.
* Returns the same builder for method chaining.
*
* @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()
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/evolution/src/sdk/builders/internal/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const makeBuild = (

yield* TxBuilderImpl.validateVoterRedeemers
yield* TxBuilderImpl.validateCertRedeemers
yield* TxBuilderImpl.validateProposalRedeemers

const { transaction, txWithFakeWitnesses } = yield* phaseStateMachine

Expand Down
Loading
Loading