Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/governance-voter-required-signers.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 41 additions & 3 deletions packages/evolution/src/sdk/builders/operations/Vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -59,12 +76,18 @@ export const createVoteProgram = (params: VoteParams): Effect.Effect<void, Trans
)
}

// 2. Collect script-controlled voters for redeemer tracking
// 2. Collect script-controlled voters for redeemer tracking, and the
// key hashes of key-hash voters for requiredSigners tracking
const scriptVoters = new Set<string>()
const keyHashVoters = new Array<KeyHash.KeyHash>()
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
Expand Down Expand Up @@ -141,11 +164,26 @@ export const createVoteProgram = (params: VoteParams): Effect.Effect<void, Trans
}
}

// Key-hash voters must sign the transaction: record them in the body's
// requiredSigners (deduplicated, like the AddSigner operation) so the
// signing step is asked for their vkey witnesses.
let newRequiredSigners = state.requiredSigners
if (keyHashVoters.length > 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
}
})

Expand Down
34 changes: 34 additions & 0 deletions packages/evolution/src/sdk/client/internal/Signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
100 changes: 100 additions & 0 deletions packages/evolution/test/Signing.GovernanceVoters.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> =>
(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([])
})
})
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof buildVote>>): Array<string> =>
(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))
})
})