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/redeemer-index-ledger-ord.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

Fix reward and vote redeemer indices to follow the ledger's `Ord` order (type-first, then hash) instead of hash-only ordering. Withdrawals are now sorted by `Ord Credential` (script-hash credentials before key-hash credentials, then hash bytes) in both assembly and evaluation, and `voterToKey` encodes each voter's credential rank (script before key) into the key so sorting voter keys reproduces `Ord Voter`. Previously, a transaction mixing a script credential and a key credential whose hashes interleaved against the type order placed the redeemer at the wrong index and was rejected by the ledger with MissingRedeemers / ExtraRedeemers.
31 changes: 28 additions & 3 deletions packages/evolution/src/sdk/builders/TransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type * as CBOR from "../../CBOR.js"
import type * as Certificate from "../../Certificate.js"
import type * as Coin from "../../Coin.js"
import type * as CostModel from "../../CostModel.js"
import type * as Credential from "../../Credential.js"
import type * as PlutusData from "../../Data.js"
import type * as KeyHash from "../../KeyHash.js"
import type * as Mint from "../../Mint.js"
Expand Down Expand Up @@ -763,6 +764,27 @@ export type ProgramStep = Effect.Effect<
// Voter Key
// ============================================================================

/**
* Compare two credentials in the ledger's `Ord Credential` order: the
* script-hash constructor sorts before the key-hash constructor, then by
* hash bytes. This is the order the ledger uses to position a key in the
* withdrawals / voting-procedures maps when it resolves a redeemer pointer
* (`indexOf`), so redeemer indices must be assigned in this order — NOT in
* serialized-byte order (a reward account's header byte puts key-hash
* credentials first, the opposite of `Ord Credential`).
*
* @since 2.0.0
* @category utilities
*/
export const compareCredentials = (a: Credential.Credential, b: Credential.Credential): number => {
const rankA = a._tag === "ScriptHash" ? 0 : 1
const rankB = b._tag === "ScriptHash" ? 0 : 1
if (rankA !== rankB) return rankA - rankB
const aHex = Bytes.toHex(a.hash)
const bHex = Bytes.toHex(b.hash)
return aHex < bHex ? -1 : aHex > bHex ? 1 : 0
}

/**
* Convert a Voter to a unique string key for redeemer tracking.
*
Expand All @@ -772,13 +794,16 @@ export type ProgramStep = Effect.Effect<
export const voterToKey = (voter: VotingProcedures.Voter): string => {
switch (voter._tag) {
case "ConstitutionalCommitteeVoter":
return `cc:${Bytes.toHex(voter.credential.hash)}`
// The middle segment is the credential's rank in `Ord Credential`
// (0 = script hash, 1 = key hash), so sorting keys as strings
// reproduces the ledger's voter order: type-first, then hash.
return `cc:${voter.credential._tag === "ScriptHash" ? 0 : 1}:${Bytes.toHex(voter.credential.hash)}`
case "DRepVoter":
switch (voter.drep._tag) {
case "KeyHashDRep":
return `drep:${Bytes.toHex(voter.drep.keyHash.hash)}`
return `drep:1:${Bytes.toHex(voter.drep.keyHash.hash)}`
case "ScriptHashDRep":
return `drep:${Bytes.toHex(voter.drep.scriptHash.hash)}`
return `drep:0:${Bytes.toHex(voter.drep.scriptHash.hash)}`
default:
return `drep:${voter.drep._tag}`
}
Expand Down
9 changes: 6 additions & 3 deletions packages/evolution/src/sdk/builders/internal/txBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type * as VotingProcedures from "../../../VotingProcedures.js"
import * as Withdrawals from "../../../Withdrawals.js"
import {
BuildOptionsTag,
compareCredentials,
FullProtocolParametersTag,
proposalKeyToIndex,
proposalToKey,
Expand Down Expand Up @@ -615,10 +616,12 @@ const makeRedeemerIndexer = (
}
}

// Reward redeemers: the withdrawal's position in sorted order
// Reward redeemers: the withdrawal's position in ledger Ord Credential
// order (script-hash credentials before key-hash, then hash bytes) — a
// hash-only sort disagrees with the ledger whenever the two interleave
const sortedWithdrawalHexes = globalThis.Array.from(state.withdrawals.keys())
.sort((a, b) => compareCredentials(a.stakeCredential, b.stakeCredential))
.map((rewardAccount) => Bytes.toHex(rewardAccount.stakeCredential.hash))
.sort((a, b) => a.localeCompare(b))

// Vote redeemers: the voter's position among the sorted voter keys
const sortedVoterKeys = state.votingProcedures
Expand Down Expand Up @@ -650,7 +653,7 @@ const makeRedeemerIndexer = (
// Key format: `reward:{credentialHex}`
return found(sortedWithdrawalHexes.indexOf(key.slice(7)))
case "vote":
// Key format: `drep:{credentialHex}` | `cc:{credentialHex}` | `pool:{poolKeyHashHex}`
// Key format: `drep:{ordRank}:{credentialHex}` | `cc:{ordRank}:{credentialHex}` | `pool:{poolKeyHashHex}`
return sortedVoterKeys ? found(sortedVoterKeys.indexOf(key)) : undefined
case "propose": {
// Key format: `propose:{index}`; proposals keep insertion order
Expand Down
16 changes: 8 additions & 8 deletions packages/evolution/src/sdk/builders/phases/Evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { assembleTransaction } from "../internal/txBuilder.js"
import type { IndexedInput } from "../RedeemerBuilder.js"
import {
BuildOptionsTag,
compareCredentials,
type DeferredRedeemerData,
type EvaluationContext,
EvaluationError,
Expand Down Expand Up @@ -419,14 +420,13 @@ export const executeEvaluation = (): Effect.Effect<
}

// Build withdrawal index mapping: index → "reward:{credentialHex}"
// Withdrawals are sorted by credential hash for canonical ordering
// Withdrawals are ordered by ledger Ord Credential (script-hash
// credentials before key-hash, then hash bytes), matching assembly
const withdrawalIndexMapping = new Map<number, string>()
if (updatedState.withdrawals.size > 0) {
const sortedWithdrawals = Array.from(updatedState.withdrawals.entries()).sort((a, b) => {
const aHex = Bytes.toHex(a[0].stakeCredential.hash)
const bHex = Bytes.toHex(b[0].stakeCredential.hash)
return aHex.localeCompare(bHex)
})
const sortedWithdrawals = Array.from(updatedState.withdrawals.entries()).sort((a, b) =>
compareCredentials(a[0].stakeCredential, b[0].stakeCredential)
)

for (let i = 0; i < sortedWithdrawals.length; i++) {
const [rewardAccount] = sortedWithdrawals[i]!
Expand All @@ -437,8 +437,8 @@ export const executeEvaluation = (): Effect.Effect<
}
}

// Build vote index mapping: index → "drep:{credentialHex}" | "cc:{credentialHex}" | "pool:{poolKeyHashHex}"
// Votes are sorted by voter key lexicographically (matching CBOR sorting)
// Build vote index mapping: index → "drep:{ordRank}:{credentialHex}" | "cc:{ordRank}:{credentialHex}" | "pool:{poolKeyHashHex}"
// Voter keys encode the ledger's Ord Voter order, so a string sort reproduces it
const voteIndexMapping = new Map<number, string>()
if (updatedState.votingProcedures) {
const voters = Array.from(updatedState.votingProcedures.procedures.keys())
Expand Down
200 changes: 200 additions & 0 deletions packages/evolution/test/TxBuilder.RedeemerIndexOrd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"

import * as CoreAddress from "../src/Address.js"
import * as Bytes from "../src/Bytes.js"
import * as Data from "../src/Data.js"
import * as DRep from "../src/DRep.js"
import * as GovernanceAction from "../src/GovernanceAction.js"
import * as KeyHash from "../src/KeyHash.js"
import * as PlutusV3 from "../src/PlutusV3.js"
import * as Redeemer from "../src/Redeemer.js"
import * as ScriptHash from "../src/ScriptHash.js"
import type { Evaluator } from "../src/sdk/builders/TransactionBuilder.js"
import { 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 TransactionHash from "../src/TransactionHash.js"
import * as VotingProcedures from "../src/VotingProcedures.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<string, number>,
PlutusV2: {} as Record<string, number>,
PlutusV3: {} as Record<string, number>
}
} satisfies ProtocolParameters

const baseConfig = { chain: mainnet }

// Any Plutus V3 script works here: the evaluator is a stub, so the script is
// never executed — only its hash (the credential / voter identity) and its
// presence in the witness set matter.
// always_succeed V3 script hash: a2492486ed656e3fb854a2c240cf16055216c38ec94d166a533c5820
const plutusScript = new PlutusV3.PlutusV3({
bytes: Bytes.fromHex(
plutusJson.validators.find((v) => v.title === "always_succeed.always_succeed.spend")!.compiledCode
)
})
const scriptCredential = ScriptHash.fromScript(plutusScript)

// A key hash that sorts BEFORE the script hash bytewise (0x11… < 0xa249…), so
// hash-only ordering and ledger Ord (ScriptHashObj before KeyHashObj) disagree.
const lowKeyHash = new KeyHash.KeyHash({ hash: new Uint8Array(28).fill(0x11) })
// A key hash that sorts AFTER the script hash bytewise (0xee… > 0xa249…), so
// both orderings agree — the control direction.
const highKeyHash = new KeyHash.KeyHash({ hash: new Uint8Array(28).fill(0xee) })

const unitsFor = (tag: string, index: number) => ({
mem: 10_000n + BigInt(index),
steps: 5_000_000n + BigInt(index)
})

// Reports ExUnits for every redeemer in the transaction
const makeEchoEvaluator = () => {
const seen: Array<Array<EvalRedeemer>> = []
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 } : {})
})

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 proceduresFor = (voters: Array<VotingProcedures.Voter>) =>
new VotingProcedures.VotingProcedures({
procedures: new Map(voters.map((v) => [v, new Map([[govActionId, yesProcedure]])] as const))
})

describe("TxBuilder redeemer indices follow ledger Ord (type-first, #404)", () => {
it("places a script-credential withdrawal redeemer at index 0 ahead of a lower-hash key withdrawal", async () => {
const { evaluator, seen } = makeEchoEvaluator()

const tx = await makeTxBuilder(baseConfig)
.withdraw({ stakeCredential: lowKeyHash, amount: 1_000_000n })
.withdraw({ stakeCredential: scriptCredential, amount: 2_000_000n, redeemer: Data.constr(0n, []) })
.attachScript({ script: plutusScript })
.build(buildOptions(evaluator))
.then((b) => b.toTransaction())

expect(tx.body.withdrawals?.withdrawals.size).toBe(2)
const redeemers = tx.witnessSet.redeemers!.toArray()
expect(redeemers).toHaveLength(1)
expect(redeemers[0]!.tag).toBe("reward")
// Ledger Ord Credential: ScriptHashObj sorts before KeyHashObj, so the
// script withdrawal is at position 0 even though its hash is larger.
expect(redeemers[0]!.index).toBe(0n)
expect(redeemers[0]!.exUnits.mem).toBe(unitsFor("reward", 0).mem)
expect(redeemers[0]!.exUnits.steps).toBe(unitsFor("reward", 0).steps)
expect(seen.flat().some((r) => r.redeemer_tag === "reward" && r.redeemer_index === 0)).toBe(true)
})

it("keeps a script-credential withdrawal redeemer at index 0 when its hash is also the lower one (control)", async () => {
const { evaluator } = makeEchoEvaluator()

const tx = await makeTxBuilder(baseConfig)
.withdraw({ stakeCredential: scriptCredential, amount: 2_000_000n, redeemer: Data.constr(0n, []) })
.withdraw({ stakeCredential: highKeyHash, amount: 1_000_000n })
.attachScript({ script: plutusScript })
.build(buildOptions(evaluator))
.then((b) => b.toTransaction())

expect(tx.body.withdrawals?.withdrawals.size).toBe(2)
const redeemers = tx.witnessSet.redeemers!.toArray()
expect(redeemers).toHaveLength(1)
expect(redeemers[0]!.tag).toBe("reward")
expect(redeemers[0]!.index).toBe(0n)
expect(redeemers[0]!.exUnits.mem).toBe(unitsFor("reward", 0).mem)
})

it("places a script DRep vote redeemer at index 0 ahead of a lower-hash key DRep voter", async () => {
const { evaluator, seen } = makeEchoEvaluator()

const scriptVoter = new VotingProcedures.DRepVoter({ drep: DRep.fromScriptHash(scriptCredential) })
const keyVoter = new VotingProcedures.DRepVoter({ drep: DRep.fromKeyHash(lowKeyHash) })

const tx = await makeTxBuilder(baseConfig)
.vote({ votingProcedures: proceduresFor([keyVoter, scriptVoter]), redeemer: Data.constr(0n, []) })
.attachScript({ script: plutusScript })
.build(buildOptions(evaluator))
.then((b) => b.toTransaction())

expect(tx.body.votingProcedures?.procedures.size).toBe(2)
const redeemers = tx.witnessSet.redeemers!.toArray()
expect(redeemers).toHaveLength(1)
expect(redeemers[0]!.tag).toBe("vote")
expect(redeemers[0]!.index).toBe(0n)
expect(redeemers[0]!.exUnits.mem).toBe(unitsFor("vote", 0).mem)
expect(redeemers[0]!.exUnits.steps).toBe(unitsFor("vote", 0).steps)
expect(seen.flat().some((r) => r.redeemer_tag === "vote" && r.redeemer_index === 0)).toBe(true)
})

it("places a script committee vote redeemer at index 0 ahead of a lower-hash key committee voter", async () => {
const { evaluator } = makeEchoEvaluator()

const scriptVoter = new VotingProcedures.ConstitutionalCommitteeVoter({ credential: scriptCredential })
const keyVoter = new VotingProcedures.ConstitutionalCommitteeVoter({ credential: lowKeyHash })

const tx = await makeTxBuilder(baseConfig)
.vote({ votingProcedures: proceduresFor([keyVoter, scriptVoter]), redeemer: Data.constr(0n, []) })
.attachScript({ script: plutusScript })
.build(buildOptions(evaluator))
.then((b) => b.toTransaction())

expect(tx.body.votingProcedures?.procedures.size).toBe(2)
const redeemers = tx.witnessSet.redeemers!.toArray()
expect(redeemers).toHaveLength(1)
expect(redeemers[0]!.tag).toBe("vote")
expect(redeemers[0]!.index).toBe(0n)
expect(redeemers[0]!.exUnits.mem).toBe(unitsFor("vote", 0).mem)
expect(redeemers[0]!.exUnits.steps).toBe(unitsFor("vote", 0).steps)
})
})