From 0ec7f4f5ccdd8f0f30835373a678ecb7f811bf3c Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Thu, 15 Oct 2026 07:58:11 +0000 Subject: [PATCH] fix(evolution): add key-hash governance voters to required signers (#479) --- .../governance-voter-required-signers.md | 5 + .../src/sdk/builders/operations/Vote.ts | 44 +++++++- .../src/sdk/client/internal/Signing.ts | 34 ++++++ .../test/Signing.GovernanceVoters.test.ts | 100 ++++++++++++++++++ ...xBuilder.GovernanceRequiredSigners.test.ts | 83 +++++++++++++++ 5 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 .changeset/governance-voter-required-signers.md create mode 100644 packages/evolution/test/Signing.GovernanceVoters.test.ts create mode 100644 packages/evolution/test/TxBuilder.GovernanceRequiredSigners.test.ts diff --git a/.changeset/governance-voter-required-signers.md b/.changeset/governance-voter-required-signers.md new file mode 100644 index 00000000..6f23c4fa --- /dev/null +++ b/.changeset/governance-voter-required-signers.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Key-hash governance voters are now added to the required signers. `computeRequiredKeyHashesSync` inspects `votingProcedures`: a key-hash DRep voter or constitutional-committee voter matching the wallet's stake credential adds the stake key, and a stake-pool voter matching the wallet's payment key hash adds the payment key, mirroring the existing certificate handling — so a seed wallet now signs a governance vote it cast. The tx builder's Vote operation likewise records key-hash voters in the body's `requiredSigners` (deduplicated), so built transactions ask for those witnesses. diff --git a/packages/evolution/src/sdk/builders/operations/Vote.ts b/packages/evolution/src/sdk/builders/operations/Vote.ts index 3b3eddb4..7daeef41 100644 --- a/packages/evolution/src/sdk/builders/operations/Vote.ts +++ b/packages/evolution/src/sdk/builders/operations/Vote.ts @@ -5,9 +5,10 @@ * @since 2.0.0 */ -import { Effect, Ref } from "effect" +import { Effect, Equal, Ref } from "effect" import type * as GovernanceAction from "../../../GovernanceAction.js" +import * as KeyHash from "../../../KeyHash.js" import * as VotingProcedures from "../../../VotingProcedures.js" import * as RedeemerBuilder from "../RedeemerBuilder.js" import { TransactionBuilderError, TxContext, voterToKey } from "../TransactionBuilder.js" @@ -28,6 +29,22 @@ const isScriptVoter = (voter: VotingProcedures.Voter): boolean => { } } +/** + * The key hash a key-hash voter must sign with, if the voter is key-hash based. + * Script-controlled voters return undefined (they sign via redeemers/scripts, + * not a vkey witness of their own). + */ +const keyHashVoter = (voter: VotingProcedures.Voter): KeyHash.KeyHash | undefined => { + switch (voter._tag) { + case "ConstitutionalCommitteeVoter": + return voter.credential._tag === "KeyHash" ? voter.credential : undefined + case "DRepVoter": + return voter.drep._tag === "KeyHashDRep" ? voter.drep.keyHash : undefined + case "StakePoolVoter": + return new KeyHash.KeyHash({ hash: voter.poolKeyHash.hash }) + } +} + /** * Creates a ProgramStep for vote operation. * Adds voting procedures to the transaction and tracks redeemers for script-controlled voters. @@ -59,12 +76,18 @@ export const createVoteProgram = (params: VoteParams): Effect.Effect() + const keyHashVoters = new Array() for (const [voter, _] of params.votingProcedures.procedures.entries()) { if (isScriptVoter(voter)) { scriptVoters.add(voterToKey(voter)) } + const keyHash = keyHashVoter(voter) + if (keyHash && !keyHashVoters.some((existing) => Equal.equals(existing, keyHash))) { + keyHashVoters.push(keyHash) + } } // 3. If redeemer provided but no script voters, warn @@ -141,11 +164,26 @@ export const createVoteProgram = (params: VoteParams): Effect.Effect 0) { + const mergedSigners = [...state.requiredSigners] + for (const keyHash of keyHashVoters) { + if (!mergedSigners.some((existing) => Equal.equals(existing, keyHash))) { + mergedSigners.push(keyHash) + } + } + newRequiredSigners = mergedSigners + } + return { ...state, votingProcedures: mergedVotingProcedures, redeemers: newRedeemers, - deferredRedeemers: newDeferredRedeemers + deferredRedeemers: newDeferredRedeemers, + requiredSigners: newRequiredSigners } }) diff --git a/packages/evolution/src/sdk/client/internal/Signing.ts b/packages/evolution/src/sdk/client/internal/Signing.ts index 9c213d2f..8b4cc5f6 100644 --- a/packages/evolution/src/sdk/client/internal/Signing.ts +++ b/packages/evolution/src/sdk/client/internal/Signing.ts @@ -110,6 +110,40 @@ const computeRequiredKeyHashesSync = (params: { } } + if (params.tx.body.votingProcedures) { + for (const voter of params.tx.body.votingProcedures.procedures.keys()) { + switch (voter._tag) { + case "DRepVoter": + // Mirrors the RegDrepCert handling below: a key-hash DRep voter is + // the wallet's stake credential. + if (params.stakeKhHex && voter.drep._tag === "KeyHashDRep") { + if (KeyHash.toHex(voter.drep.keyHash) === params.stakeKhHex) { + required.add(params.stakeKhHex) + } + } + break + case "ConstitutionalCommitteeVoter": + // Mirrors the AuthCommitteeHotCert handling below, which likewise + // matches the committee credential against the stake key hash. + if (params.stakeKhHex && voter.credential._tag === "KeyHash") { + if (KeyHash.toHex(voter.credential) === params.stakeKhHex) { + required.add(params.stakeKhHex) + } + } + break + case "StakePoolVoter": + // Mirrors the PoolRegistration/PoolRetirement handling below: a + // stake pool voter is identified by the pool (payment) key hash. + if (params.paymentKhHex) { + if (PoolKeyHash.toHex(voter.poolKeyHash) === params.paymentKhHex) { + required.add(params.paymentKhHex) + } + } + break + } + } + } + if (params.tx.body.certificates && (params.stakeKhHex || params.paymentKhHex)) { for (const certificate of params.tx.body.certificates) { if (params.stakeKhHex) { diff --git a/packages/evolution/test/Signing.GovernanceVoters.test.ts b/packages/evolution/test/Signing.GovernanceVoters.test.ts new file mode 100644 index 00000000..cda2fa62 --- /dev/null +++ b/packages/evolution/test/Signing.GovernanceVoters.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest" + +import * as DRep from "../src/DRep.js" +import * as GovernanceAction from "../src/GovernanceAction.js" +import * as KeyHash from "../src/KeyHash.js" +import * as PoolKeyHash from "../src/PoolKeyHash.js" +import * as PrivateKey from "../src/PrivateKey.js" +import * as ScriptHash from "../src/ScriptHash.js" +import { preprod } from "../src/sdk/client/Chain.js" +import { seedWallet } from "../src/sdk/client/internal/Wallets.js" +import { keysFromSeed } from "../src/sdk/wallet/Derivation.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionBody from "../src/TransactionBody.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as VotingProcedures from "../src/VotingProcedures.js" + +const mnemonic = + "zebra short room flavor rival capital fortune hip profit trust melody office depend adapt visa cycle february link tornado whisper physical kiwi film voyage" + +const keys = keysFromSeed(mnemonic) +const stakeKey = PrivateKey.fromBech32(keys.stakeKey!) +const paymentKey = PrivateKey.fromBech32(keys.paymentKey) +const stakeKh = KeyHash.fromPrivateKey(stakeKey) +const paymentKh = KeyHash.fromPrivateKey(paymentKey) + +const govActionId = new GovernanceAction.GovActionId({ + transactionId: new TransactionHash.TransactionHash({ hash: new Uint8Array(32).fill(0xcd) }), + govActionIndex: 0n +}) +const yesProcedure = new VotingProcedures.VotingProcedure({ vote: VotingProcedures.yes(), anchor: null }) + +const txWithVoter = (voter: VotingProcedures.Voter): Transaction.Transaction => + new Transaction.Transaction({ + body: new TransactionBody.TransactionBody({ + inputs: [], + outputs: [], + fee: 200_000n, + votingProcedures: VotingProcedures.singleVote(voter, govActionId, yesProcedure) + }), + witnessSet: TransactionWitnessSet.empty(), + isValid: true, + auxiliaryData: null + }) + +const witnessKeyHashHexes = (ws: TransactionWitnessSet.TransactionWitnessSet): Array => + (ws.vkeyWitnesses ?? []).map((w) => KeyHash.toHex(KeyHash.fromVKey(w.vkey))) + +describe("signTx adds governance voters to the required signers (#479)", () => { + it("key-hash DRep voter equal to the wallet stake credential is signed with the stake key", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + const voter = new VotingProcedures.DRepVoter({ drep: new DRep.KeyHashDRep({ keyHash: stakeKh }) }) + + const witnessSet = await wallet.signTx(txWithVoter(voter)) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(stakeKh)]) + }) + + it("key-hash committee voter equal to the wallet stake credential is signed with the stake key", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + const voter = new VotingProcedures.ConstitutionalCommitteeVoter({ credential: stakeKh }) + + const witnessSet = await wallet.signTx(txWithVoter(voter)) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(stakeKh)]) + }) + + it("stake-pool voter equal to the wallet payment key hash is signed with the payment key", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + const voter = new VotingProcedures.StakePoolVoter({ + poolKeyHash: new PoolKeyHash.PoolKeyHash({ hash: paymentKh.hash }) + }) + + const witnessSet = await wallet.signTx(txWithVoter(voter)) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(paymentKh)]) + }) + + it("script-hash DRep voter produces no vkey witness (control)", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + const voter = new VotingProcedures.DRepVoter({ + drep: new DRep.ScriptHashDRep({ scriptHash: ScriptHash.fromHex("11".repeat(28)) }) + }) + + const witnessSet = await wallet.signTx(txWithVoter(voter)) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([]) + }) + + it("unrelated key-hash DRep voter produces no vkey witness (control)", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + const voter = new VotingProcedures.DRepVoter({ + drep: new DRep.KeyHashDRep({ keyHash: KeyHash.fromHex("22".repeat(28)) }) + }) + + const witnessSet = await wallet.signTx(txWithVoter(voter)) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([]) + }) +}) diff --git a/packages/evolution/test/TxBuilder.GovernanceRequiredSigners.test.ts b/packages/evolution/test/TxBuilder.GovernanceRequiredSigners.test.ts new file mode 100644 index 00000000..f862bef1 --- /dev/null +++ b/packages/evolution/test/TxBuilder.GovernanceRequiredSigners.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "@effect/vitest" + +import * as CoreAddress from "../src/Address.js" +import * as DRep from "../src/DRep.js" +import * as GovernanceAction from "../src/GovernanceAction.js" +import * as KeyHash from "../src/KeyHash.js" +import * as PoolKeyHash from "../src/PoolKeyHash.js" +import type { TxBuilderConfig } from "../src/sdk/builders/TransactionBuilder.js" +import { makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js" +import { mainnet } from "../src/sdk/client/index.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as VotingProcedures from "../src/VotingProcedures.js" +import { createCoreTestUtxo } from "./utils/utxo-helpers.js" + +const PROTOCOL_PARAMS = { + minFeeCoefficient: 44n, + minFeeConstant: 155_381n, + coinsPerUtxoByte: 4_310n, + maxTxSize: 16_384 +} + +const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" + +const baseConfig = { chain: mainnet } satisfies TxBuilderConfig + +const govActionId = new GovernanceAction.GovActionId({ + transactionId: new TransactionHash.TransactionHash({ hash: new Uint8Array(32).fill(0xcd) }), + govActionIndex: 0n +}) + +const yesProcedure = new VotingProcedures.VotingProcedure({ vote: VotingProcedures.yes(), anchor: null }) + +const buildVote = async (voter: VotingProcedures.Voter) => { + const walletUtxo = createCoreTestUtxo({ + transactionId: "a".repeat(64), + index: 0n, + address: CHANGE_ADDRESS, + lovelace: 100_000_000n + }) + + const signBuilder = await makeTxBuilder(baseConfig) + .vote({ votingProcedures: VotingProcedures.singleVote(voter, govActionId, yesProcedure) }) + .build({ + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: [walletUtxo], + protocolParameters: PROTOCOL_PARAMS + }) + + return signBuilder.toTransaction() +} + +const requiredSignerHexes = (tx: Awaited>): Array => + (tx.body.requiredSigners ?? []).map((kh) => KeyHash.toHex(kh)) + +describe("TxBuilder Vote adds key-hash voters to requiredSigners (#479)", () => { + it("key-hash DRep voter is added to the body's requiredSigners", async () => { + const keyHash = KeyHash.fromHex("aa".repeat(28)) + const voter = new VotingProcedures.DRepVoter({ drep: new DRep.KeyHashDRep({ keyHash }) }) + + const tx = await buildVote(voter) + + expect(requiredSignerHexes(tx)).toContain(KeyHash.toHex(keyHash)) + }) + + it("key-hash committee voter is added to the body's requiredSigners", async () => { + const keyHash = KeyHash.fromHex("bb".repeat(28)) + const voter = new VotingProcedures.ConstitutionalCommitteeVoter({ credential: keyHash }) + + const tx = await buildVote(voter) + + expect(requiredSignerHexes(tx)).toContain(KeyHash.toHex(keyHash)) + }) + + it("stake-pool voter is added to the body's requiredSigners", async () => { + const poolKeyHash = new PoolKeyHash.PoolKeyHash({ hash: KeyHash.fromHex("cc".repeat(28)).hash }) + const voter = new VotingProcedures.StakePoolVoter({ poolKeyHash }) + + const tx = await buildVote(voter) + + expect(requiredSignerHexes(tx)).toContain("cc".repeat(28)) + }) +})