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
7 changes: 7 additions & 0 deletions .changeset/committee-cert-redeemer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@evolution-sdk/evolution": patch
---

`authCommitteeHot` and `resignCommitteeCold` now build with a Plutus-script cold credential. The builder emits the certificate's redeemer at the certificate's index and evaluates it. Before, neither transaction assembly nor script evaluation matched the committee cold credential to its certificate, so the build failed in evaluation.

A redeemer that the builder cannot place at an index now fails `build()` and names the redeemer key. Before, the builder logged a warning and left the redeemer out of the transaction, which the node then rejected. `mintAssets` no longer registers a redeemer for a policy whose amounts are all zero, since that policy is not minted.
49 changes: 27 additions & 22 deletions packages/evolution/src/sdk/builders/internal/txBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Bytes from "../../../Bytes.js"
import * as CBOR from "../../../CBOR.js"
import type * as Certificate from "../../../Certificate.js"
import * as CostModel from "../../../CostModel.js"
import type * as Credential from "../../../Credential.js"
import * as PlutusData from "../../../Data.js"
import type * as DatumHash from "../../../DatumHash.js"
import type * as DatumOption from "../../../DatumOption.js"
Expand Down Expand Up @@ -289,6 +290,14 @@ const voterScriptHashHex = (voter: VotingProcedures.Voter): string | undefined =
}
}

/** The credential a certificate's `cert:{credentialHex}` redeemer key names, if any. */
export const certCredential = (certificate: Certificate.Certificate): Credential.Credential | undefined => {
if ("stakeCredential" in certificate) return certificate.stakeCredential
if ("drepCredential" in certificate) return certificate.drepCredential
if ("committeeColdCredential" in certificate) return certificate.committeeColdCredential
return undefined
}

/**
* Resolve the governing credential hash (as hex) for certificates whose authorization
* can be script-controlled: the DRep lifecycle certificates (DRep credential) and the
Expand All @@ -300,14 +309,11 @@ const certScriptHashHex = (certificate: Certificate.Certificate): string | undef
case "RegDrepCert":
case "UpdateDrepCert":
case "UnregDrepCert":
return certificate.drepCredential._tag === "ScriptHash"
? Bytes.toHex(certificate.drepCredential.hash)
: undefined
case "AuthCommitteeHotCert":
case "ResignCommitteeColdCert":
return certificate.committeeColdCredential._tag === "ScriptHash"
? Bytes.toHex(certificate.committeeColdCredential.hash)
: undefined
case "ResignCommitteeColdCert": {
const credential = certCredential(certificate)!
return credential._tag === "ScriptHash" ? Bytes.toHex(credential.hash) : undefined
}
default:
return undefined
}
Expand Down Expand Up @@ -818,19 +824,14 @@ const makeRedeemerIndexer = (
case "mint":
return mintIndexMap.get(key)
case "cert": {
// Key format: `cert:{credentialHex}`, matched against the stake
// credential or the DRep credential of each certificate in order
// Key format: `cert:{credentialHex}`
const credentialHex = key.slice(5)
for (let i = 0; i < state.certificates.length; i++) {
const cert = state.certificates[i]!
if ("stakeCredential" in cert && cert.stakeCredential) {
if (Bytes.toHex((cert.stakeCredential as { hash: Uint8Array }).hash) === credentialHex) return i
}
if ("drepCredential" in cert && cert.drepCredential) {
if (Bytes.toHex((cert.drepCredential as { hash: Uint8Array }).hash) === credentialHex) return i
}
}
return undefined
return found(
state.certificates.findIndex((cert) => {
const credential = certCredential(cert)
return credential !== undefined && Bytes.toHex(credential.hash) === credentialHex
})
)
}
case "reward":
// Key format: `reward:{credentialHex}`
Expand Down Expand Up @@ -975,8 +976,12 @@ export const assembleTransaction = (

const redeemerIndex = redeemerIndexOf(key, redeemerData.tag)
if (redeemerIndex === undefined) {
yield* Effect.logWarning(`[Assembly] Could not find the ${redeemerData.tag} index for redeemer key: ${key}`)
continue
return yield* Effect.fail(
new TransactionBuilderError({
message: `Could not find the ${redeemerData.tag} index for redeemer key: ${key}`,
cause: { key, tag: redeemerData.tag }
})
)
}

yield* Effect.logDebug(
Expand Down Expand Up @@ -1449,7 +1454,7 @@ export const buildFakeWitnessSet = (
const redeemerIndexOf = makeRedeemerIndexer(state, inputs)
for (const [key, redeemerData] of state.redeemers) {
// The index assembly writes: an index of 24 or more takes one more CBOR
// byte, and the fee must cover it. Assembly leaves out a redeemer with no index
// byte, and the fee must cover it. Assembly fails on a redeemer with no index
const index = redeemerIndexOf(key, redeemerData.tag)
if (index === undefined) continue

Expand Down
6 changes: 3 additions & 3 deletions packages/evolution/src/sdk/builders/operations/Mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ export const createMintAssetsProgram = (
const policyIds = new Set<string>()

for (const [policyId, assetMap] of params.assets.multiAsset.map.entries()) {
// Track policy IDs for redeemer association
policyIds.add(PolicyId.toHex(policyId))

for (const [assetName, amount] of assetMap.entries()) {
if (amount === 0n) continue

// Track policy IDs for redeemer association
policyIds.add(PolicyId.toHex(policyId))

// Validate amount is within NonZeroInt64 range
if (amount < NonZeroInt64.NEG_INT64_MIN || amount > NonZeroInt64.POS_INT64_MAX) {
return yield* Effect.fail(
Expand Down
19 changes: 5 additions & 14 deletions packages/evolution/src/sdk/builders/phases/Evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as PolicyId from "../../../PolicyId.js"
import * as CoreUTxO from "../../../UTxO.js"
import type * as Provider from "../../provider/Provider.js"
import * as EvaluationStateManager from "../EvaluationStateManager.js"
import { assembleTransaction } from "../internal/txBuilder.js"
import { assembleTransaction, certCredential } from "../internal/txBuilder.js"
import type { IndexedInput } from "../RedeemerBuilder.js"
import {
BuildOptionsTag,
Expand Down Expand Up @@ -401,20 +401,11 @@ export const executeEvaluation = (): Effect.Effect<
// Certificate redeemers are indexed by their position in the certificates array
const certIndexMapping = new Map<number, string>()
for (let i = 0; i < updatedState.certificates.length; i++) {
const cert = updatedState.certificates[i]!
// Handle stake-related certificates (stakeCredential)
if ("stakeCredential" in cert && cert.stakeCredential) {
const credHex = Bytes.toHex(cert.stakeCredential.hash)
const key = `cert:${credHex}`
const credential = certCredential(updatedState.certificates[i]!)
if (credential !== undefined) {
const key = `cert:${Bytes.toHex(credential.hash)}`
certIndexMapping.set(i, key)
yield* Effect.logDebug(`[Evaluation] Cert ${i} maps to stake credential: ${key}`)
}
// Handle DRep-related certificates (drepCredential): RegDrepCert, UnregDrepCert, UpdateDrepCert
else if ("drepCredential" in cert && cert.drepCredential) {
const credHex = Bytes.toHex(cert.drepCredential.hash)
const key = `cert:${credHex}`
certIndexMapping.set(i, key)
yield* Effect.logDebug(`[Evaluation] Cert ${i} maps to drep credential: ${key}`)
yield* Effect.logDebug(`[Evaluation] Cert ${i} maps to credential: ${key}`)
}
}

Expand Down
91 changes: 91 additions & 0 deletions packages/evolution/test/TxBuilder.MintEmptyPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"

import * as CoreAddress from "../src/Address.js"
import * as CoreAssets from "../src/Assets.js"
import * as Bytes from "../src/Bytes.js"
import * as Data from "../src/Data.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 { ProtocolParameters } from "../src/sdk/provider/Provider.js"
import * as Text from "../src/Text.js"
import type * as Transaction from "../src/Transaction.js"
import plutusJson from "./spec/plutus.json"
import { createCoreTestUtxo } from "./utils/utxo-helpers.js"

const CHANGE_ADDRESS =
"addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae"

const 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 loadScript = (title: string) =>
new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(plutusJson.validators.find((v) => v.title === title)!.compiledCode) })

const script = loadScript("simple_mint.simple_mint.mint")
const unit = ScriptHash.toHex(ScriptHash.fromScript(script)) + Text.toHex("Token")
const emptyUnit =
ScriptHash.toHex(ScriptHash.fromScript(loadScript("always_succeed.always_succeed.spend"))) + Text.toHex("Token")
const redeemer = Data.constr(0n, [])

const evaluator: Evaluator = {
evaluate: (tx) =>
Effect.sync(() =>
(tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({
redeemer_tag: r.tag,
redeemer_index: Number(r.index),
ex_units: new Redeemer.ExUnits({ mem: 10_000n, steps: 5_000_000n })
}))
)
}

const buildOptions = () => ({
changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS),
availableUtxos: [
createCoreTestUtxo({ transactionId: "a".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 2_000_000_000n }),
createCoreTestUtxo({ transactionId: "b".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 20_000_000n })
],
fullProtocolParameters: PROTOCOL_PARAMS,
evaluator
})

const redeemersOf = (tx: Transaction.Transaction) =>
(tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({ tag: r.tag, index: r.index }))

describe("TxBuilder mint policy with nothing to mint", () => {
it("takes no redeemer for a policy whose amounts are all zero", async () => {
const tx = await makeTxBuilder({ chain: mainnet })
.mintAssets({ assets: CoreAssets.fromRecord({ [unit]: 1n, [emptyUnit]: 0n }), redeemer })
.attachScript({ script })
.build(buildOptions())
.then((b) => b.toTransaction())

expect(tx.body.mint?.map.size).toBe(1)
expect(redeemersOf(tx)).toEqual([{ tag: "mint", index: 0n }])
})
})
123 changes: 123 additions & 0 deletions packages/evolution/test/TxBuilder.PlutusScriptCCCert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 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 { ProtocolParameters } from "../src/sdk/provider/Provider.js"
import type * as Transaction from "../src/Transaction.js"
import plutusJson from "./spec/plutus.json"
import { createCoreTestUtxo } from "./utils/utxo-helpers.js"

const CHANGE_ADDRESS =
"addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae"

const 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 script = new PlutusV3.PlutusV3({
bytes: Bytes.fromHex(
plutusJson.validators.find((v) => v.title === "always_succeed.always_succeed.spend")!.compiledCode
)
})
const scriptColdCredential = ScriptHash.fromScript(script)
const keyColdCredential = new KeyHash.KeyHash({ hash: new Uint8Array(28).fill(0x22) })
const hotCredential = new KeyHash.KeyHash({ hash: new Uint8Array(28).fill(0x11) })
const redeemer = Data.constr(0n, [])

const evaluator: Evaluator = {
evaluate: (tx) =>
Effect.sync(() =>
(tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({
redeemer_tag: r.tag,
redeemer_index: Number(r.index),
ex_units: new Redeemer.ExUnits({ mem: 10_000n, steps: 5_000_000n })
}))
)
}

const buildOptions = () => ({
changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS),
availableUtxos: [
createCoreTestUtxo({ transactionId: "a".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 2_000_000_000n }),
createCoreTestUtxo({ transactionId: "b".repeat(64), index: 0n, address: CHANGE_ADDRESS, lovelace: 20_000_000n })
],
fullProtocolParameters: PROTOCOL_PARAMS,
evaluator
})

const redeemersOf = (tx: Transaction.Transaction) =>
(tx.witnessSet.redeemers?.toArray() ?? []).map((r) => ({ tag: r.tag, index: r.index }))

describe("TxBuilder Plutus-script CC certificate", () => {
it("authorizes a hot credential for a Plutus-script cold credential", async () => {
const tx = await makeTxBuilder({ chain: mainnet })
.authCommitteeHot({ coldCredential: scriptColdCredential, hotCredential, redeemer })
.attachScript({ script })
.build(buildOptions())
.then((b) => b.toTransaction())

expect(redeemersOf(tx)).toEqual([{ tag: "cert", index: 0n }])
expect(tx.witnessSet.plutusV3Scripts?.length).toBe(1)
expect(tx.body.scriptDataHash).toBeDefined()
})

it("indexes the redeemer at the committee certificate's own position", async () => {
const tx = await makeTxBuilder({ chain: mainnet })
.authCommitteeHot({ coldCredential: keyColdCredential, hotCredential })
.authCommitteeHot({ coldCredential: scriptColdCredential, hotCredential, redeemer })
.attachScript({ script })
.build(buildOptions())
.then((b) => b.toTransaction())

expect(tx.body.certificates?.length).toBe(2)
expect(redeemersOf(tx)).toEqual([{ tag: "cert", index: 1n }])
})

it("resigns a Plutus-script cold credential", async () => {
const tx = await makeTxBuilder({ chain: mainnet })
.resignCommitteeCold({ coldCredential: scriptColdCredential, anchor: null, redeemer })
.attachScript({ script })
.build(buildOptions())
.then((b) => b.toTransaction())

expect(redeemersOf(tx)).toEqual([{ tag: "cert", index: 0n }])
})

it("emits no redeemer for a key-hash cold credential", async () => {
const tx = await makeTxBuilder({ chain: mainnet })
.authCommitteeHot({ coldCredential: keyColdCredential, hotCredential, redeemer })
.build(buildOptions())
.then((b) => b.toTransaction())

expect(tx.witnessSet.redeemers).toBeUndefined()
})
})
Loading