From e903ede951d563e0da98cb9bd9cdb3b4cd434a35 Mon Sep 17 00:00:00 2001 From: lisicky Date: Mon, 12 Oct 2026 18:19:45 +0200 Subject: [PATCH] feat: add vote and proposal part builders --- .../src/tx-builder/parts/certificates.ts | 194 +------ .../src/tx-builder/parts/credentials.ts | 206 ++++++++ .../src/tx-builder/parts/governance.ts | 476 +++++++++++++++++ .../src/tx-builder/parts/index.ts | 1 + .../src/tx-builder/parts/types.ts | 62 ++- .../src/tx-builder/parts/unresolved.ts | 9 + .../test/tx-builder/parts/governance.test.ts | 486 ++++++++++++++++++ 7 files changed, 1248 insertions(+), 186 deletions(-) create mode 100644 packages/mesh-transaction/src/tx-builder/parts/credentials.ts create mode 100644 packages/mesh-transaction/src/tx-builder/parts/governance.ts create mode 100644 packages/mesh-transaction/test/tx-builder/parts/governance.test.ts diff --git a/packages/mesh-transaction/src/tx-builder/parts/certificates.ts b/packages/mesh-transaction/src/tx-builder/parts/certificates.ts index 4ece0373b..bdb8ea6bd 100644 --- a/packages/mesh-transaction/src/tx-builder/parts/certificates.ts +++ b/packages/mesh-transaction/src/tx-builder/parts/certificates.ts @@ -1,7 +1,4 @@ import type { - AnchorPrototype, - CredTypePrototype, - DRepPrototype, Ipv4Prototype, Ipv6Prototype, PoolParams, @@ -9,10 +6,18 @@ import type { Relay, RelayPrototype, } from "@meshsdk/common"; -import { Cardano } from "@meshsdk/core-cst"; import { - checkCredential, + toAnchor, + toCredential, + toCredentialWitness, + toDeposit, + toDRep, + toDRepCredential, + toPoolKeyHash, + toPrototypeCredential, +} from "./credentials"; +import { fail, freeze, isHash28, @@ -20,10 +25,6 @@ import { labelOf, PartContext, rewardCredential, - toNativeScriptSource, - toPlutusScriptSource, - toRedeemer, - withKnownHash, } from "./normalize"; import { AnchorInput, @@ -33,187 +34,14 @@ import { CredentialWitnessInput, DRepCredentialInput, DRepInput, - KeyWitness, - NativeWitness, - PlutusWitness, PoolInput, } from "./types"; -type Credential = { type: "key" | "script"; hash: string }; - -const toPrototypeCredential = (credential: Credential): CredTypePrototype => - credential.type === "script" - ? { type: "SCRIPT", value: credential.hash } - : { type: "KEY", value: credential.hash }; - -const fromPrototypeCredential = ( - ctx: PartContext, - credential: CredTypePrototype, -): Credential => { - const hash = - typeof credential.value === "string" ? credential.value.toLowerCase() : ""; - if ( - (credential.type !== "KEY" && credential.type !== "SCRIPT") || - !isHash28(hash) - ) - fail( - ctx, - "INVALID_DATA", - `invalid credential ${JSON.stringify(credential)}`, - ); - return { type: credential.type === "SCRIPT" ? "script" : "key", hash }; -}; - -/** A stake (or committee) credential, from a bech32 reward address or as given */ -const toCredential = (ctx: PartContext, input: CredentialInput): Credential => - typeof input === "string" - ? rewardCredential(ctx, input) - : fromPrototypeCredential(ctx, input); - -const toDRepCredential = ( - ctx: PartContext, - input: DRepCredentialInput, -): Credential => { - if (typeof input !== "string") return fromPrototypeCredential(ctx, input); - if (!Cardano.DRepID.isValid(input)) - return fail(ctx, "INVALID_DATA", `invalid DRep id ${input}`); - const credential = Cardano.DRepID.toCredential(Cardano.DRepID(input)); - return { - type: - credential.type === Cardano.CredentialType.ScriptHash ? "script" : "key", - hash: credential.hash.toString(), - }; -}; - -const toDRep = (ctx: PartContext, input: DRepInput): DRepPrototype => { - if (input === "alwaysAbstain") return { type: "ALWAYS_ABSTAIN" }; - if (input === "alwaysNoConfidence") return { type: "ALWAYS_NO_CONFIDENCE" }; - if (typeof input === "object" && "type" in input) { - if ( - input.type === "ALWAYS_ABSTAIN" || - input.type === "ALWAYS_NO_CONFIDENCE" - ) - return { type: input.type }; - if (input.type === "KEY_HASH" || input.type === "SCRIPT_HASH") { - const credential = fromPrototypeCredential(ctx, { - type: input.type === "SCRIPT_HASH" ? "SCRIPT" : "KEY", - value: input.value, - }); - return { type: input.type, value: credential.hash }; - } - } - const credential = toDRepCredential(ctx, input as DRepCredentialInput); - return credential.type === "script" - ? { type: "SCRIPT_HASH", value: credential.hash } - : { type: "KEY_HASH", value: credential.hash }; -}; - -const toPoolKeyHash = (ctx: PartContext, pool: PoolInput): string => { - if (typeof pool === "string" && isHash28(pool.toLowerCase())) - return pool.toLowerCase(); - try { - return Cardano.PoolId.toKeyHash(Cardano.PoolId(pool)).toString(); - } catch { - return fail(ctx, "INVALID_DATA", `invalid pool id ${pool}`); - } -}; - -/** Anchor URLs are at most 128 bytes */ -const MAX_ANCHOR_URL_BYTES = 128; - -const toAnchor = ( - ctx: PartContext, - anchor: AnchorInput | undefined, -): AnchorPrototype | undefined => { - if (anchor === undefined) return undefined; - const dataHash = - typeof anchor.dataHash === "string" ? anchor.dataHash.toLowerCase() : ""; - if (!isHash32(dataHash)) - fail(ctx, "INVALID_DATA", `invalid anchor data hash ${anchor.dataHash}`); - if ( - typeof anchor.url !== "string" || - new TextEncoder().encode(anchor.url).length > MAX_ANCHOR_URL_BYTES - ) - fail(ctx, "INVALID_DATA", `invalid anchor url ${anchor.url}`); - return { anchor_url: anchor.url, anchor_data_hash: dataHash }; -}; - -/** A deposit or refund: as given, or left to the named protocol parameter */ -const toDeposit = ( - ctx: PartContext, - amount: bigint | undefined, - parameter: "keyDeposit" | "drepDeposit", -): { coin?: string; depositParameter?: "keyDeposit" | "drepDeposit" } => { - if (amount === undefined) return { depositParameter: parameter }; - if (typeof amount !== "bigint" || amount < 0n) - fail(ctx, "INVALID_VALUE", `invalid deposit ${amount}`); - return { coin: amount.toString() }; -}; - -/** - * What authorises a certificate for a credential: the key for a key credential, which takes - * no witness argument, or the given script for a script credential, which must have one. - * Certificates introduced with governance can only be witnessed by Plutus V3 scripts. - */ -const toCredentialWitness = ( - ctx: PartContext, - credential: Credential, - witness: CredentialWitnessInput | undefined, - { plutusV3Only }: { plutusV3Only: boolean }, -): KeyWitness | NativeWitness | PlutusWitness => { - const what = `${credential.type} credential ${credential.hash}`; - if (credential.type === "key") { - if (witness) - fail( - ctx, - "WITNESS_MISMATCH", - `${what} is authorised by its key, not a script`, - ); - return { type: "key" }; - } - if (!witness) - return fail( - ctx, - "WITNESS_MISMATCH", - `${what} needs a witness: { native } or { plutus, redeemer }`, - ); - if ("native" in witness) { - const script = withKnownHash( - toNativeScriptSource(ctx, witness.native), - credential, - ); - checkCredential(ctx, what, credential, { - type: "script", - hash: script.scriptHash, - }); - return { type: "native", script }; - } - const script = withKnownHash( - toPlutusScriptSource(ctx, witness.plutus), - credential, - ); - checkCredential(ctx, what, credential, { - type: "script", - hash: script.scriptHash, - }); - if (plutusV3Only && script.version !== undefined && script.version !== "V3") - fail( - ctx, - "WITNESS_MISMATCH", - `this certificate can only be witnessed by a Plutus V3 script, not ${script.version}`, - ); - return { - type: "plutus", - script, - redeemer: toRedeemer(ctx, witness.redeemer), - }; -}; - const build = ( ctx: PartContext, certificate: CertificateFragment, witness: CertificatePart["witness"], - depositParameter?: "keyDeposit" | "drepDeposit", + depositParameter?: CertificatePart["depositParameter"], ): CertificatePart => freeze({ kind: "certificate", diff --git a/packages/mesh-transaction/src/tx-builder/parts/credentials.ts b/packages/mesh-transaction/src/tx-builder/parts/credentials.ts new file mode 100644 index 000000000..87e960be7 --- /dev/null +++ b/packages/mesh-transaction/src/tx-builder/parts/credentials.ts @@ -0,0 +1,206 @@ +import type { + AnchorPrototype, + CredTypePrototype, + DRepPrototype, +} from "@meshsdk/common"; +import { Cardano } from "@meshsdk/core-cst"; + +import { + checkCredential, + fail, + isHash28, + isHash32, + PartContext, + rewardCredential, + toNativeScriptSource, + toPlutusScriptSource, + toRedeemer, + withKnownHash, +} from "./normalize"; +import { + AnchorInput, + CredentialInput, + CredentialWitnessInput, + DepositParameter, + DRepCredentialInput, + DRepInput, + KeyWitness, + NativeWitness, + PlutusWitness, + PoolInput, +} from "./types"; + +export type Credential = { type: "key" | "script"; hash: string }; + +export const toPrototypeCredential = ( + credential: Credential, +): CredTypePrototype => + credential.type === "script" + ? { type: "SCRIPT", value: credential.hash } + : { type: "KEY", value: credential.hash }; + +export const fromPrototypeCredential = ( + ctx: PartContext, + credential: CredTypePrototype, +): Credential => { + const hash = + typeof credential.value === "string" ? credential.value.toLowerCase() : ""; + if ( + (credential.type !== "KEY" && credential.type !== "SCRIPT") || + !isHash28(hash) + ) + fail( + ctx, + "INVALID_DATA", + `invalid credential ${JSON.stringify(credential)}`, + ); + return { type: credential.type === "SCRIPT" ? "script" : "key", hash }; +}; + +/** A stake (or committee) credential, from a bech32 reward address or as given */ +export const toCredential = ( + ctx: PartContext, + input: CredentialInput, +): Credential => + typeof input === "string" + ? rewardCredential(ctx, input) + : fromPrototypeCredential(ctx, input); + +export const toDRepCredential = ( + ctx: PartContext, + input: DRepCredentialInput, +): Credential => { + if (typeof input !== "string") return fromPrototypeCredential(ctx, input); + if (!Cardano.DRepID.isValid(input)) + return fail(ctx, "INVALID_DATA", `invalid DRep id ${input}`); + const credential = Cardano.DRepID.toCredential(Cardano.DRepID(input)); + return { + type: + credential.type === Cardano.CredentialType.ScriptHash ? "script" : "key", + hash: credential.hash.toString(), + }; +}; + +export const toDRep = (ctx: PartContext, input: DRepInput): DRepPrototype => { + if (input === "alwaysAbstain") return { type: "ALWAYS_ABSTAIN" }; + if (input === "alwaysNoConfidence") return { type: "ALWAYS_NO_CONFIDENCE" }; + if (typeof input === "object" && "type" in input) { + if ( + input.type === "ALWAYS_ABSTAIN" || + input.type === "ALWAYS_NO_CONFIDENCE" + ) + return { type: input.type }; + if (input.type === "KEY_HASH" || input.type === "SCRIPT_HASH") { + const credential = fromPrototypeCredential(ctx, { + type: input.type === "SCRIPT_HASH" ? "SCRIPT" : "KEY", + value: input.value, + }); + return { type: input.type, value: credential.hash }; + } + } + const credential = toDRepCredential(ctx, input as DRepCredentialInput); + return credential.type === "script" + ? { type: "SCRIPT_HASH", value: credential.hash } + : { type: "KEY_HASH", value: credential.hash }; +}; + +export const toPoolKeyHash = (ctx: PartContext, pool: PoolInput): string => { + if (typeof pool === "string" && isHash28(pool.toLowerCase())) + return pool.toLowerCase(); + try { + return Cardano.PoolId.toKeyHash(Cardano.PoolId(pool)).toString(); + } catch { + return fail(ctx, "INVALID_DATA", `invalid pool id ${pool}`); + } +}; + +/** Anchor URLs are at most 128 bytes */ +const MAX_ANCHOR_URL_BYTES = 128; + +export const toAnchor = ( + ctx: PartContext, + anchor: AnchorInput | undefined, +): AnchorPrototype | undefined => { + if (anchor === undefined) return undefined; + const dataHash = + typeof anchor.dataHash === "string" ? anchor.dataHash.toLowerCase() : ""; + if (!isHash32(dataHash)) + fail(ctx, "INVALID_DATA", `invalid anchor data hash ${anchor.dataHash}`); + if ( + typeof anchor.url !== "string" || + new TextEncoder().encode(anchor.url).length > MAX_ANCHOR_URL_BYTES + ) + fail(ctx, "INVALID_DATA", `invalid anchor url ${anchor.url}`); + return { anchor_url: anchor.url, anchor_data_hash: dataHash }; +}; + +/** A deposit or refund: as given, or left to the named protocol parameter */ +export const toDeposit =

( + ctx: PartContext, + amount: bigint | undefined, + parameter: P, +): { coin?: string; depositParameter?: P } => { + if (amount === undefined) return { depositParameter: parameter }; + if (typeof amount !== "bigint" || amount < 0n) + fail(ctx, "INVALID_VALUE", `invalid deposit ${amount}`); + return { coin: amount.toString() }; +}; + +/** + * What authorises a certificate for a credential: the key for a key credential, which takes + * no witness argument, or the given script for a script credential, which must have one. + * Certificates introduced with governance can only be witnessed by Plutus V3 scripts. + */ +export const toCredentialWitness = ( + ctx: PartContext, + credential: Credential, + witness: CredentialWitnessInput | undefined, + { plutusV3Only }: { plutusV3Only: boolean }, +): KeyWitness | NativeWitness | PlutusWitness => { + const what = `${credential.type} credential ${credential.hash}`; + if (credential.type === "key") { + if (witness) + fail( + ctx, + "WITNESS_MISMATCH", + `${what} is authorised by its key, not a script`, + ); + return { type: "key" }; + } + if (!witness) + return fail( + ctx, + "WITNESS_MISMATCH", + `${what} needs a witness: { native } or { plutus, redeemer }`, + ); + if ("native" in witness) { + const script = withKnownHash( + toNativeScriptSource(ctx, witness.native), + credential, + ); + checkCredential(ctx, what, credential, { + type: "script", + hash: script.scriptHash, + }); + return { type: "native", script }; + } + const script = withKnownHash( + toPlutusScriptSource(ctx, witness.plutus), + credential, + ); + checkCredential(ctx, what, credential, { + type: "script", + hash: script.scriptHash, + }); + if (plutusV3Only && script.version !== undefined && script.version !== "V3") + fail( + ctx, + "WITNESS_MISMATCH", + `this certificate can only be witnessed by a Plutus V3 script, not ${script.version}`, + ); + return { + type: "plutus", + script, + redeemer: toRedeemer(ctx, witness.redeemer), + }; +}; diff --git a/packages/mesh-transaction/src/tx-builder/parts/governance.ts b/packages/mesh-transaction/src/tx-builder/parts/governance.ts new file mode 100644 index 000000000..a7e4c67b2 --- /dev/null +++ b/packages/mesh-transaction/src/tx-builder/parts/governance.ts @@ -0,0 +1,476 @@ +import type { + GovernanceActionIdPrototype, + GovernanceActionPrototype, + ProtocolParamUpdatePrototype, + UnitIntervalPrototype, + VotePrototype, + VoterPrototype, +} from "@meshsdk/common"; + +import { + Credential, + toAnchor, + toCredential, + toCredentialWitness, + toDeposit, + toDRepCredential, + toPoolKeyHash, + toPrototypeCredential, +} from "./credentials"; +import { + fail, + freeze, + isHash28, + isHash32, + labelOf, + PartContext, + rewardCredential, + toNativeScriptSource, + toPlutusScriptSource, +} from "./normalize"; +import { + AnchorInput, + CredentialInput, + CredentialWitnessInput, + GovActionIdInput, + ProposalPart, + VoteInput, + VotePart, + VoterInput, +} from "./types"; + +const toGovActionId = ( + ctx: PartContext, + id: GovActionIdInput, +): GovernanceActionIdPrototype => { + const txHash = typeof id?.txHash === "string" ? id.txHash.toLowerCase() : ""; + if ( + !isHash32(txHash) || + !Number.isInteger(id.index) || + id.index < 0 || + id.index > 0xffff + ) + fail( + ctx, + "INVALID_DATA", + `invalid governance action id ${JSON.stringify(id)}`, + ); + return { transaction_id: txHash, index: id.index }; +}; + +const VOTE_KINDS = { yes: "YES", no: "NO", abstain: "ABSTAIN" } as const; + +/** + * Cast the votes of one voter. All of a voter's votes go in one part, as the ledger runs the + * voter's script once for all of them. + * @param params.voter `{ drep }` (a DRep id or credential), `{ committee }` (a hot credential, + * or a reward address carrying it) or `{ pool }` (a pool id or key hash) + * @param params.votes The governance actions voted on, each at most once + * @param params.witness The script authorising a script DRep or committee credential; pools + * vote with their key + * @param params.label Name echoed in errors about this part, such as a script failure + */ +export const vote = (params: { + voter: VoterInput; + votes: VoteInput[]; + witness?: CredentialWitnessInput; + label?: string; +}): VotePart => { + const ctx: PartContext = { part: "vote", label: params.label }; + const { voter: voterInput } = params; + + let voter: VoterPrototype; + let credential: Credential | undefined; + if ("pool" in voterInput) { + if (params.witness) + fail( + ctx, + "WITNESS_MISMATCH", + "a stake pool votes with its key, not a script", + ); + voter = { + type: "STAKING_POOL", + value: toPoolKeyHash(ctx, voterInput.pool), + }; + } else if ("drep" in voterInput) { + credential = toDRepCredential(ctx, voterInput.drep); + voter = { type: "DREP", value: toPrototypeCredential(credential) }; + } else if ("committee" in voterInput) { + credential = toCredential(ctx, voterInput.committee); + voter = { + type: "CONSTITUTIONAL_COMMITTEE_HOT_CRED", + value: toPrototypeCredential(credential), + }; + } else { + return fail( + ctx, + "INVALID_DATA", + `invalid voter ${JSON.stringify(voterInput)}`, + ); + } + + if (!Array.isArray(params.votes) || params.votes.length === 0) + fail(ctx, "INVALID_DATA", "no votes"); + const seen = new Set(); + const votes = params.votes.map((input): VotePrototype => { + const action = toGovActionId(ctx, input.action); + const key = `${action.transaction_id}#${action.index}`; + if (seen.has(key)) + fail(ctx, "INVALID_DATA", `governance action ${key} voted on twice`); + seen.add(key); + const kind = VOTE_KINDS[input.vote]; + if (!kind) fail(ctx, "INVALID_DATA", `invalid vote ${input.vote}`); + const anchor = toAnchor(ctx, input.anchor); + return { + action_id: action, + voting_procedure: { vote: { type: kind }, ...(anchor && { anchor }) }, + }; + }); + + return freeze({ + kind: "vote", + voter, + votes, + witness: credential + ? toCredentialWitness(ctx, credential, params.witness, { + plutusV3Only: true, + }) + : { type: "key" }, + ...labelOf(params.label), + }); +}; + +/* --------------------------------------------------------------------------------------------- + * Proposals + * ------------------------------------------------------------------------------------------- */ + +type ProposalParams = { + /** The reward address the deposit is returned to */ + rewardAccount: string; + /** Where the proposal's rationale is published */ + anchor: AnchorInput; + /** Lovelace deposited; the `govActionDeposit` protocol parameter when omitted */ + deposit?: bigint; + label?: string; +}; + +type GuardedParams = { + /** + * The constitution's guardrail script, run to authorise the proposal. Its hash goes in the + * action's policy hash. + */ + guardrail?: CredentialWitnessInput; + /** The guardrail's hash; needed only for a guardrail referenced by out ref alone */ + policyHash?: string; +}; + +type ChainedParams = { + /** The last enacted action of the same purpose; left out if there is none yet */ + previous?: GovActionIdInput; +}; + +const ctxOf = (label?: string): PartContext => ({ part: "proposal", label }); + +const toGuardrail = ( + ctx: PartContext, + { guardrail, policyHash }: GuardedParams, +): { policyHash?: string; witness: ProposalPart["witness"] } => { + const given = policyHash?.toLowerCase(); + if (given !== undefined && !isHash28(given)) + fail(ctx, "INVALID_SCRIPT", `invalid policy hash ${policyHash}`); + if (!guardrail) { + if (given) + fail( + ctx, + "WITNESS_MISMATCH", + "a policy hash needs its guardrail script as witness", + ); + return { witness: { type: "none" } }; + } + const scriptHash = + "native" in guardrail + ? toNativeScriptSource(ctx, guardrail.native).scriptHash + : toPlutusScriptSource(ctx, guardrail.plutus).scriptHash; + const hash = given ?? scriptHash; + if (!hash) + return fail( + ctx, + "INVALID_SCRIPT", + "the guardrail's hash is unknown: pass policyHash, or the reference UTxO in full", + ); + const witness = toCredentialWitness( + ctx, + { type: "script", hash }, + guardrail, + { + plutusV3Only: true, + }, + ); + return { policyHash: hash, witness: witness as ProposalPart["witness"] }; +}; + +const build = ( + ctx: PartContext, + params: ProposalParams, + governanceAction: GovernanceActionPrototype, + witness: ProposalPart["witness"] = { type: "none" }, +): ProposalPart => { + rewardCredential(ctx, params.rewardAccount); + const anchor = toAnchor(ctx, params.anchor); + if (!anchor) return fail(ctx, "INVALID_DATA", "a proposal needs an anchor"); + const { coin, depositParameter } = toDeposit( + ctx, + params.deposit, + "govActionDeposit", + ); + return freeze({ + kind: "proposal", + proposal: { + governance_action: governanceAction, + anchor, + reward_account: params.rewardAccount, + ...(coin && { deposit: coin }), + }, + ...(depositParameter && { depositParameter }), + witness, + ...labelOf(params.label), + }); +}; + +const previousOf = (ctx: PartContext, previous?: GovActionIdInput) => + previous ? { gov_action_id: toGovActionId(ctx, previous) } : {}; + +const toUnitInterval = ( + ctx: PartContext, + name: string, + value: { + numerator: bigint | number | string; + denominator: bigint | number | string; + }, +): UnitIntervalPrototype => { + const numerator = String(value?.numerator); + const denominator = String(value?.denominator); + if ( + !/^\d+$/.test(numerator) || + !/^\d+$/.test(denominator) || + BigInt(denominator) === 0n + ) + fail(ctx, "INVALID_VALUE", `invalid ${name} ${numerator}/${denominator}`); + return { numerator, denominator }; +}; + +/** + * Checks a protocol parameter update field by field: amounts and counts must be non-negative + * integers (cost model entries may be negative), ratios must have a non-zero denominator. + */ +const checkParamUpdate = ( + ctx: PartContext, + updates: ProtocolParamUpdatePrototype, +) => { + const present = Object.entries(updates ?? {}).filter(([, v]) => v != null); + if (present.length === 0) + fail(ctx, "INVALID_DATA", "no parameters to update"); + const check = (path: string, value: unknown, signed: boolean): void => { + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) + fail(ctx, "INVALID_VALUE", `invalid ${path}: ${value}`); + } else if (typeof value === "string") { + if (!(signed ? /^-?\d+$/ : /^\d+$/).test(value)) + fail(ctx, "INVALID_VALUE", `invalid ${path}: ${value}`); + } else if (Array.isArray(value)) { + value.forEach((item, i) => check(`${path}[${i}]`, item, signed)); + } else if (value !== null && typeof value === "object") { + if ("numerator" in value && "denominator" in value) + toUnitInterval(ctx, path, value as UnitIntervalPrototype); + else + for (const [key, item] of Object.entries(value)) + check(`${path}.${key}`, item, signed); + } else { + fail(ctx, "INVALID_VALUE", `invalid ${path}: ${String(value)}`); + } + }; + for (const [key, value] of present) check(key, value, key === "cost_models"); +}; + +export const proposal = { + /** + * Propose changing protocol parameters. + * @param params.updates The new values, in the ledger's shape + * @param params.guardrail The constitution's guardrail script, when it has one + */ + parameterChange: ( + params: ProposalParams & + GuardedParams & + ChainedParams & { updates: ProtocolParamUpdatePrototype }, + ): ProposalPart => { + const ctx = ctxOf(params.label); + checkParamUpdate(ctx, params.updates); + const { policyHash, witness } = toGuardrail(ctx, params); + return build( + ctx, + params, + { + type: "PARAMETER_CHANGE_ACTION", + value: { + protocol_param_updates: params.updates, + ...previousOf(ctx, params.previous), + ...(policyHash && { policy_hash: policyHash }), + }, + }, + witness, + ); + }, + + /** + * Propose a hard fork to a new protocol version. + * @param params.protocolVersion The version to fork to + */ + hardForkInitiation: ( + params: ProposalParams & + ChainedParams & { protocolVersion: { major: number; minor: number } }, + ): ProposalPart => { + const ctx = ctxOf(params.label); + const { major, minor } = params.protocolVersion ?? {}; + for (const [name, value] of [ + ["major", major], + ["minor", minor], + ] as const) + if (!Number.isSafeInteger(value) || value! < 0 || value! > 0xffffffff) + fail(ctx, "INVALID_DATA", `invalid protocol version ${name} ${value}`); + return build(ctx, params, { + type: "HARD_FORK_INITIATION_ACTION", + value: { + protocol_version: { major: major!, minor: minor! }, + ...previousOf(ctx, params.previous), + }, + }); + }, + + /** + * Propose withdrawing lovelace from the treasury. + * @param params.withdrawals Lovelace by receiving reward address + * @param params.guardrail The constitution's guardrail script, when it has one + */ + treasuryWithdrawals: ( + params: ProposalParams & + GuardedParams & { withdrawals: Record }, + ): ProposalPart => { + const ctx = ctxOf(params.label); + const entries = Object.entries(params.withdrawals ?? {}); + if (entries.length === 0) fail(ctx, "INVALID_VALUE", "no withdrawals"); + const withdrawals: Record = {}; + for (const [address, amount] of entries) { + rewardCredential(ctx, address); + if (typeof amount !== "bigint" || amount < 0n) + fail( + ctx, + "INVALID_VALUE", + `invalid withdrawal ${amount} to ${address}`, + ); + withdrawals[address] = amount.toString(); + } + const { policyHash, witness } = toGuardrail(ctx, params); + return build( + ctx, + params, + { + type: "TREASURY_WITHDRAWALS_ACTION", + value: { withdrawals, ...(policyHash && { policy_hash: policyHash }) }, + }, + witness, + ); + }, + + /** Propose a motion of no confidence in the constitutional committee. */ + noConfidence: (params: ProposalParams & ChainedParams): ProposalPart => { + const ctx = ctxOf(params.label); + return build(ctx, params, { + type: "NO_CONFIDENCE_ACTION", + value: { ...previousOf(ctx, params.previous) }, + }); + }, + + /** + * Propose changing the constitutional committee. + * @param params.remove Cold credentials of members to remove + * @param params.add Members to add, with the epoch their term ends at + * @param params.quorum The share of members whose votes are needed + */ + updateCommittee: ( + params: ProposalParams & + ChainedParams & { + remove?: CredentialInput[]; + add?: { credential: CredentialInput; termLimit: bigint | number }[]; + quorum: { numerator: bigint | number; denominator: bigint | number }; + }, + ): ProposalPart => { + const ctx = ctxOf(params.label); + const quorum = toUnitInterval(ctx, "quorum", params.quorum); + if (BigInt(quorum.numerator) > BigInt(quorum.denominator)) + fail( + ctx, + "INVALID_VALUE", + `quorum ${quorum.numerator}/${quorum.denominator} exceeds 1`, + ); + const members = (params.add ?? []).map(({ credential, termLimit }) => { + if ( + (typeof termLimit !== "bigint" && !Number.isSafeInteger(termLimit)) || + termLimit < 0 || + BigInt(termLimit) >= 2n ** 64n + ) + fail(ctx, "INVALID_DATA", `invalid term limit ${termLimit}`); + return { + stake_credential: toPrototypeCredential(toCredential(ctx, credential)), + term_limit: BigInt(termLimit), + }; + }); + return build(ctx, params, { + type: "UPDATE_COMMITTEE_ACTION", + value: { + committee: { members, quorum_threshold: quorum }, + members_to_remove: (params.remove ?? []).map((credential) => + toPrototypeCredential(toCredential(ctx, credential)), + ), + ...previousOf(ctx, params.previous), + }, + }); + }, + + /** + * Propose a new constitution. + * @param params.constitution Where the constitution is published, and its guardrail script's + * hash, if any + */ + newConstitution: ( + params: ProposalParams & + ChainedParams & { + constitution: { anchor: AnchorInput; scriptHash?: string }; + }, + ): ProposalPart => { + const ctx = ctxOf(params.label); + const anchor = toAnchor(ctx, params.constitution?.anchor); + if (!anchor) + return fail(ctx, "INVALID_DATA", "a constitution needs an anchor"); + const scriptHash = params.constitution.scriptHash?.toLowerCase(); + if (scriptHash !== undefined && !isHash28(scriptHash)) + fail( + ctx, + "INVALID_SCRIPT", + `invalid guardrail script hash ${scriptHash}`, + ); + return build(ctx, params, { + type: "NEW_CONSTITUTION_ACTION", + value: { + constitution: { + anchor, + ...(scriptHash && { script_hash: scriptHash }), + }, + ...previousOf(ctx, params.previous), + }, + }); + }, + + /** Propose an info action, voted on but with no effect on the chain. */ + info: (params: ProposalParams): ProposalPart => + build(ctxOf(params.label), params, { type: "INFO_ACTION" }), +}; diff --git a/packages/mesh-transaction/src/tx-builder/parts/index.ts b/packages/mesh-transaction/src/tx-builder/parts/index.ts index cda29b722..2c061c830 100644 --- a/packages/mesh-transaction/src/tx-builder/parts/index.ts +++ b/packages/mesh-transaction/src/tx-builder/parts/index.ts @@ -5,6 +5,7 @@ export { output } from "./output"; export { mint } from "./mint"; export { withdrawal } from "./withdrawal"; export { certificate } from "./certificates"; +export { vote, proposal } from "./governance"; export { requiredSigner, validity, diff --git a/packages/mesh-transaction/src/tx-builder/parts/types.ts b/packages/mesh-transaction/src/tx-builder/parts/types.ts index cc9a68e1f..a55dcf8d9 100644 --- a/packages/mesh-transaction/src/tx-builder/parts/types.ts +++ b/packages/mesh-transaction/src/tx-builder/parts/types.ts @@ -20,6 +20,9 @@ import type { TransactionInputPrototype, UTxO, ValuePrototype, + VotePrototype, + VoterPrototype, + VotingProposalPrototype, } from "@meshsdk/common"; /* @@ -148,6 +151,26 @@ export type CredentialWitnessInput = | { native: NativeScriptInput } | { plutus: PlutusScriptInput; redeemer: RedeemerInput }; +/** A governance action: the transaction that proposed it and the proposal's position there */ +export type GovActionIdInput = { txHash: string; index: number }; + +/** Who votes: a DRep, a constitutional committee member's hot credential, or a stake pool */ +export type VoterInput = + | { drep: DRepCredentialInput } + | { committee: CredentialInput } + | { pool: PoolInput }; + +export type VoteInput = { + action: GovActionIdInput; + vote: "yes" | "no" | "abstain"; + /** Where the rationale is published */ + anchor?: AnchorInput; +}; + +/** The protocol parameters deposits and refunds are taken from */ +export type DepositParameter = + "keyDeposit" | "drepDeposit" | "govActionDeposit"; + /* --------------------------------------------------------------------------------------------- * Parts * ------------------------------------------------------------------------------------------- */ @@ -372,7 +395,7 @@ export type CertificatePart = PartBase & { readonly kind: "certificate"; readonly certificate: Readonly; /** The protocol parameter the missing deposit or refund is taken from */ - readonly depositParameter?: "keyDeposit" | "drepDeposit"; + readonly depositParameter?: Exclude; /** * What authorises the certificate: nothing for a legacy stake registration; the keys it names * for pool certificates; otherwise its credential's key or script @@ -381,6 +404,37 @@ export type CertificatePart = PartBase & { { readonly type: "none" } | KeyWitness | NativeWitness | PlutusWitness; }; +/** + * The votes of one voter. A transaction holds at most one part per voter, as the ledger runs a + * voter's script, and takes its redeemer, once for all of that voter's votes. + */ +export type VotePart = PartBase & { + readonly kind: "vote"; + readonly voter: Readonly; + readonly votes: readonly Readonly[]; + readonly witness: KeyWitness | NativeWitness | PlutusWitness; +}; + +/** + * A governance proposal. Proposals keep the order their parts are given in, as that order is + * part of the transaction. + */ +export type ProposalPart = PartBase & { + readonly kind: "proposal"; + readonly proposal: Readonly< + Omit & { + /** Left out to take the `govActionDeposit` protocol parameter */ + deposit?: string; + } + >; + readonly depositParameter?: "govActionDeposit"; + /** + * The guardrail script a parameter change or treasury withdrawal names in its policy hash; + * nothing for every other proposal + */ + readonly witness: { readonly type: "none" } | NativeWitness | PlutusWitness; +}; + export type TxPart = | SpendPart | ReferenceInputPart @@ -396,7 +450,9 @@ export type TxPart = | DonationPart | TreasuryValuePart | NetworkIdPart - | CertificatePart; + | CertificatePart + | VotePart + | ProposalPart; /** Something a part leaves to be filled in before assembly */ export type Unresolved = @@ -420,5 +476,5 @@ export type Unresolved = /** A deposit or refund, from a protocol parameter */ | { readonly type: "deposit"; - readonly parameter: "keyDeposit" | "drepDeposit"; + readonly parameter: DepositParameter; }; diff --git a/packages/mesh-transaction/src/tx-builder/parts/unresolved.ts b/packages/mesh-transaction/src/tx-builder/parts/unresolved.ts index 65d533fec..56359e6b2 100644 --- a/packages/mesh-transaction/src/tx-builder/parts/unresolved.ts +++ b/packages/mesh-transaction/src/tx-builder/parts/unresolved.ts @@ -78,6 +78,15 @@ export const unresolved = (part: TxPart): Unresolved[] => { : []), ...(part.witness.type === "none" ? [] : witnessGaps(part.witness)), ]; + case "vote": + return witnessGaps(part.witness); + case "proposal": + return [ + ...(part.depositParameter + ? [{ type: "deposit" as const, parameter: part.depositParameter }] + : []), + ...(part.witness.type === "none" ? [] : witnessGaps(part.witness)), + ]; case "requiredSigner": case "validity": case "metadata": diff --git a/packages/mesh-transaction/test/tx-builder/parts/governance.test.ts b/packages/mesh-transaction/test/tx-builder/parts/governance.test.ts new file mode 100644 index 000000000..f60dd7574 --- /dev/null +++ b/packages/mesh-transaction/test/tx-builder/parts/governance.test.ts @@ -0,0 +1,486 @@ +import { + mConStr0, + NativeScript, + resolveNativeScriptHash, + resolveScriptHash, + TransactionPrototype, +} from "@meshsdk/core"; +import { + Cardano, + transactionPrototypeFromHex, + transactionPrototypeToHex, +} from "@meshsdk/core-cst"; + +import { + proposal, + ProposalPart, + TxPartError, + unresolved, + vote, + VotePart, +} from "../../../src/tx-builder"; +import { + alwaysSucceedCbor, + alwaysSucceedHash, + drepBech32, + keyHashHex, + rewardAddress, +} from "../../test-util"; + +const drep = drepBech32(3); +const drepHash = Cardano.DRepID.toCredential( + Cardano.DRepID(drep), +).hash.toString(); +const poolHash = keyHashHex(7); +const action = { txHash: "a".repeat(64), index: 0 }; +const otherAction = { txHash: "b".repeat(64), index: 2 }; +const anchor = { url: "https://example.com/r.json", dataHash: "cd".repeat(32) }; +const plutusWitness = { + plutus: { code: alwaysSucceedCbor, version: "V3" as const }, + redeemer: { data: mConStr0([]) }, +}; +const nativeScript: NativeScript = { type: "all", scripts: [] }; +const returnAddress = rewardAddress(1); +const common = { rewardAccount: returnAddress, anchor }; + +const expectPartError = (build: () => unknown, code: string) => { + try { + build(); + } catch (error) { + expect(error).toBeInstanceOf(TxPartError); + expect((error as TxPartError).code).toBe(code); + return; + } + throw new Error(`expected a ${code} error`); +}; + +describe("vote", () => { + it("casts all of a DRep's votes in one part", () => { + const part = vote({ + voter: { drep }, + votes: [ + { action, vote: "yes" }, + { action: otherAction, vote: "abstain", anchor }, + ], + label: "ballot", + }); + + expect(part).toEqual({ + kind: "vote", + voter: { type: "DREP", value: { type: "KEY", value: drepHash } }, + votes: [ + { + action_id: { transaction_id: "a".repeat(64), index: 0 }, + voting_procedure: { vote: { type: "YES" } }, + }, + { + action_id: { transaction_id: "b".repeat(64), index: 2 }, + voting_procedure: { + vote: { type: "ABSTAIN" }, + anchor: { + anchor_url: anchor.url, + anchor_data_hash: anchor.dataHash, + }, + }, + }, + ], + witness: { type: "key" }, + label: "ballot", + }); + expect(unresolved(part)).toEqual([]); + }); + + it("votes as a script committee member with a Plutus V3 witness", () => { + const part = vote({ + voter: { committee: { type: "SCRIPT", value: alwaysSucceedHash } }, + votes: [{ action, vote: "no" }], + witness: plutusWitness, + }); + + expect(part.voter).toEqual({ + type: "CONSTITUTIONAL_COMMITTEE_HOT_CRED", + value: { type: "SCRIPT", value: alwaysSucceedHash }, + }); + expect(unresolved(part)).toEqual([{ type: "exUnits" }]); + + const v2Hash = resolveScriptHash(alwaysSucceedCbor, "V2"); + expectPartError( + () => + vote({ + voter: { committee: { type: "SCRIPT", value: v2Hash } }, + votes: [{ action, vote: "no" }], + witness: { + ...plutusWitness, + plutus: { code: alwaysSucceedCbor, version: "V2" }, + }, + }), + "WITNESS_MISMATCH", + ); + }); + + it("votes as a stake pool with its key only", () => { + const part = vote({ + voter: { pool: poolHash }, + votes: [{ action, vote: "yes" }], + }); + expect(part.voter).toEqual({ type: "STAKING_POOL", value: poolHash }); + expect(part.witness).toEqual({ type: "key" }); + + expectPartError( + () => + vote({ + voter: { pool: poolHash }, + votes: [{ action, vote: "yes" }], + witness: { native: nativeScript }, + }), + "WITNESS_MISMATCH", + ); + }); + + it("needs a witness for a script DRep", () => { + expectPartError( + () => + vote({ + voter: { + drep: { + type: "SCRIPT", + value: resolveNativeScriptHash(nativeScript), + }, + }, + votes: [{ action, vote: "yes" }], + }), + "WITNESS_MISMATCH", + ); + }); + + it("rejects no votes, repeated actions and malformed ids", () => { + expectPartError(() => vote({ voter: { drep }, votes: [] }), "INVALID_DATA"); + expectPartError( + () => + vote({ + voter: { drep }, + votes: [ + { action, vote: "yes" }, + { action, vote: "no" }, + ], + }), + "INVALID_DATA", + ); + expectPartError( + () => + vote({ + voter: { drep }, + votes: [{ action: { txHash: "a", index: 0 }, vote: "yes" }], + }), + "INVALID_DATA", + ); + expectPartError( + () => + vote({ + voter: { drep }, + votes: [{ action, vote: "maybe" as "yes" }], + }), + "INVALID_DATA", + ); + }); +}); + +describe("proposal", () => { + it("leaves the deposit to govActionDeposit unless given", () => { + const pending = proposal.info(common); + expect(pending.proposal).toEqual({ + governance_action: { type: "INFO_ACTION" }, + anchor: { anchor_url: anchor.url, anchor_data_hash: anchor.dataHash }, + reward_account: returnAddress, + }); + expect(pending.witness).toEqual({ type: "none" }); + expect(unresolved(pending)).toEqual([ + { type: "deposit", parameter: "govActionDeposit" }, + ]); + + const stated = proposal.info({ ...common, deposit: 100_000_000_000n }); + expect(stated.proposal.deposit).toBe("100000000000"); + expect(unresolved(stated)).toEqual([]); + }); + + it("changes parameters, naming the guardrail it runs", () => { + const part = proposal.parameterChange({ + ...common, + updates: { + max_tx_size: 16384, + key_deposit: "2000000", + ref_script_coins_per_byte: { numerator: "15", denominator: "1" }, + }, + previous: action, + guardrail: plutusWitness, + }); + + expect(part.proposal.governance_action).toEqual({ + type: "PARAMETER_CHANGE_ACTION", + value: { + protocol_param_updates: { + max_tx_size: 16384, + key_deposit: "2000000", + ref_script_coins_per_byte: { numerator: "15", denominator: "1" }, + }, + gov_action_id: { transaction_id: "a".repeat(64), index: 0 }, + policy_hash: alwaysSucceedHash, + }, + }); + expect(part.witness).toMatchObject({ type: "plutus" }); + }); + + it("rejects malformed parameter updates and a policy hash without guardrail", () => { + expectPartError( + () => proposal.parameterChange({ ...common, updates: {} }), + "INVALID_DATA", + ); + for (const updates of [ + { max_tx_size: -1 }, + { key_deposit: "1.5" }, + { treasury_growth_rate: { numerator: "1", denominator: "0" } }, + ]) { + expectPartError( + () => proposal.parameterChange({ ...common, updates }), + "INVALID_VALUE", + ); + } + expectPartError( + () => + proposal.parameterChange({ + ...common, + updates: { max_tx_size: 1 }, + policyHash: alwaysSucceedHash, + }), + "WITNESS_MISMATCH", + ); + }); + + it("needs the policy hash of a guardrail referenced by out ref alone", () => { + const guardrail = { + native: { reference: { txHash: "c".repeat(64), outputIndex: 0 } }, + }; + expectPartError( + () => + proposal.treasuryWithdrawals({ + ...common, + withdrawals: { [returnAddress]: 1n }, + guardrail, + }), + "INVALID_SCRIPT", + ); + + const part = proposal.treasuryWithdrawals({ + ...common, + withdrawals: { [returnAddress]: 1n }, + guardrail, + policyHash: alwaysSucceedHash, + }); + expect(part.proposal.governance_action).toEqual({ + type: "TREASURY_WITHDRAWALS_ACTION", + value: { + withdrawals: { [returnAddress]: "1" }, + policy_hash: alwaysSucceedHash, + }, + }); + expect(part.witness).toMatchObject({ + type: "native", + script: { type: "reference", scriptHash: alwaysSucceedHash }, + }); + }); + + it("builds the remaining actions", () => { + expect( + proposal.hardForkInitiation({ + ...common, + protocolVersion: { major: 11, minor: 0 }, + }).proposal.governance_action, + ).toEqual({ + type: "HARD_FORK_INITIATION_ACTION", + value: { protocol_version: { major: 11, minor: 0 } }, + }); + expect( + proposal.noConfidence({ ...common, previous: action }).proposal + .governance_action, + ).toEqual({ + type: "NO_CONFIDENCE_ACTION", + value: { gov_action_id: { transaction_id: "a".repeat(64), index: 0 } }, + }); + expect( + proposal.updateCommittee({ + ...common, + remove: [{ type: "KEY", value: keyHashHex(1) }], + add: [ + { + credential: { type: "SCRIPT", value: alwaysSucceedHash }, + termLimit: 700, + }, + ], + quorum: { numerator: 2, denominator: 3 }, + }).proposal.governance_action, + ).toEqual({ + type: "UPDATE_COMMITTEE_ACTION", + value: { + committee: { + members: [ + { + stake_credential: { type: "SCRIPT", value: alwaysSucceedHash }, + term_limit: 700n, + }, + ], + quorum_threshold: { numerator: "2", denominator: "3" }, + }, + members_to_remove: [{ type: "KEY", value: keyHashHex(1) }], + }, + }); + expect( + proposal.newConstitution({ + ...common, + constitution: { anchor, scriptHash: alwaysSucceedHash }, + }).proposal.governance_action, + ).toEqual({ + type: "NEW_CONSTITUTION_ACTION", + value: { + constitution: { + anchor: { anchor_url: anchor.url, anchor_data_hash: anchor.dataHash }, + script_hash: alwaysSucceedHash, + }, + }, + }); + }); + + it("rejects bad addresses, quorums and versions", () => { + expectPartError( + () => proposal.info({ ...common, rewardAccount: "stake1nope" }), + "INVALID_ADDRESS", + ); + expectPartError( + () => + proposal.updateCommittee({ + ...common, + quorum: { numerator: 4, denominator: 3 }, + }), + "INVALID_VALUE", + ); + expectPartError( + () => + proposal.hardForkInitiation({ + ...common, + protocolVersion: { major: -1, minor: 0 }, + }), + "INVALID_DATA", + ); + expectPartError( + () => proposal.treasuryWithdrawals({ ...common, withdrawals: {} }), + "INVALID_VALUE", + ); + }); +}); + +describe("governance parts as TransactionPrototype fragments", () => { + const encode = (votes: VotePart[], proposals: ProposalPart[]) => { + const proto: TransactionPrototype = { + body: { + inputs: [{ transaction_id: "a".repeat(64), index: 0 }], + outputs: [], + fee: "200000", + voting_procedures: votes.map((part) => ({ + voter: part.voter, + votes: [...part.votes], + })), + voting_proposals: proposals.map((part) => ({ + ...part.proposal, + deposit: part.proposal.deposit ?? "100000000000", + })), + }, + witness_set: {}, + is_valid: true, + }; + return { proto, hex: transactionPrototypeToHex(proto) }; + }; + + it("survive encoding to CBOR and decoding back", () => { + const { proto, hex } = encode( + [ + vote({ + voter: { drep }, + votes: [ + { action, vote: "yes", anchor }, + { action: otherAction, vote: "no" }, + ], + }), + vote({ + voter: { pool: poolHash }, + votes: [{ action, vote: "abstain" }], + }), + vote({ + voter: { committee: { type: "KEY", value: keyHashHex(2) } }, + votes: [{ action, vote: "yes" }], + }), + ], + [ + proposal.info(common), + proposal.hardForkInitiation({ + ...common, + protocolVersion: { major: 11, minor: 0 }, + previous: action, + }), + proposal.treasuryWithdrawals({ + ...common, + withdrawals: { [returnAddress]: 5n }, + }), + proposal.noConfidence(common), + proposal.updateCommittee({ + ...common, + remove: [{ type: "KEY", value: keyHashHex(1) }], + add: [ + { + credential: { type: "KEY", value: keyHashHex(3) }, + termLimit: 700, + }, + ], + quorum: { numerator: 2, denominator: 3 }, + }), + proposal.newConstitution({ ...common, constitution: { anchor } }), + ], + ); + + const decoded = transactionPrototypeFromHex(hex); + const withoutNulls = (value: unknown): unknown => + Array.isArray(value) + ? value.map(withoutNulls) + : value !== null && typeof value === "object" + ? Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== null) + .map(([k, v]) => [k, withoutNulls(v)]), + ) + : value; + expect(withoutNulls(decoded.body.voting_procedures)).toEqual( + withoutNulls(proto.body.voting_procedures), + ); + expect(withoutNulls(decoded.body.voting_proposals)).toEqual( + withoutNulls(proto.body.voting_proposals), + ); + }); + + it("encodes a parameter change", () => { + // Parameter changes are encoded, but deliberately not decoded back + expect(() => + encode( + [], + [ + proposal.parameterChange({ + ...common, + updates: { + max_tx_size: 16384, + key_deposit: "2000000", + ref_script_coins_per_byte: { numerator: "15", denominator: "1" }, + }, + guardrail: plutusWitness, + }), + ], + ), + ).not.toThrow(); + }); +});