diff --git a/.changeset/redeemer-index-ledger-ord.md b/.changeset/redeemer-index-ledger-ord.md new file mode 100644 index 00000000..0817c384 --- /dev/null +++ b/.changeset/redeemer-index-ledger-ord.md @@ -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. diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 35d5bfad..0f1e7b83 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -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" @@ -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. * @@ -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}` } diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index 1590c1e5..cedcaa8a 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -40,6 +40,7 @@ import type * as VotingProcedures from "../../../VotingProcedures.js" import * as Withdrawals from "../../../Withdrawals.js" import { BuildOptionsTag, + compareCredentials, FullProtocolParametersTag, proposalKeyToIndex, proposalToKey, @@ -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 @@ -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 diff --git a/packages/evolution/src/sdk/builders/phases/Evaluation.ts b/packages/evolution/src/sdk/builders/phases/Evaluation.ts index 5f36f200..1433ec64 100644 --- a/packages/evolution/src/sdk/builders/phases/Evaluation.ts +++ b/packages/evolution/src/sdk/builders/phases/Evaluation.ts @@ -21,6 +21,7 @@ import { assembleTransaction } from "../internal/txBuilder.js" import type { IndexedInput } from "../RedeemerBuilder.js" import { BuildOptionsTag, + compareCredentials, type DeferredRedeemerData, type EvaluationContext, EvaluationError, @@ -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() 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]! @@ -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() if (updatedState.votingProcedures) { const voters = Array.from(updatedState.votingProcedures.procedures.keys()) diff --git a/packages/evolution/test/TxBuilder.RedeemerIndexOrd.test.ts b/packages/evolution/test/TxBuilder.RedeemerIndexOrd.test.ts new file mode 100644 index 00000000..86cd6665 --- /dev/null +++ b/packages/evolution/test/TxBuilder.RedeemerIndexOrd.test.ts @@ -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, + PlutusV2: {} as Record, + PlutusV3: {} as Record + } +} 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> = [] + 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) => + 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) + }) +})