Skip to content

Commit 0d918ea

Browse files
committed
feat: add missing apis
1 parent 98b59fa commit 0d918ea

27 files changed

+2425
-101
lines changed

packages/evolution-devnet/test/TxBuilder.RedeemerBuilder.test.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import * as PlutusV3 from "@evolution-sdk/evolution/core/PlutusV3"
1919
import * as PolicyId from "@evolution-sdk/evolution/core/PolicyId"
2020
import * as ScriptHash from "@evolution-sdk/evolution/core/ScriptHash"
2121
import * as Text from "@evolution-sdk/evolution/core/Text"
22-
import type { IndexedInput } from "@evolution-sdk/evolution/sdk/builders/RedeemerBuilder"
2322
import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"
2423
import { Schema } from "effect"
2524

@@ -28,9 +27,7 @@ import plutusJson from "../../evolution/test/spec/plutus.json"
2827
const CoreAssets = Core.Assets
2928

3029
const getMintMultiValidator = () => {
31-
const validator = plutusJson.validators.find(
32-
(v) => v.title === "mint_multi_validator.mint_multi_validator.spend"
33-
)
30+
const validator = plutusJson.validators.find((v) => v.title === "mint_multi_validator.mint_multi_validator.spend")
3431

3532
if (!validator) {
3633
throw new Error("mint_multi_validator not found in plutus.json")
@@ -53,20 +50,14 @@ describe("TxBuilder RedeemerBuilder", () => {
5350
"test test test test test test test test test test test test test test test test test test test test test test test sauce"
5451

5552
/** SpendRedeemer: Constr(0, [value: Int]) where value = datum.counter + input_index */
56-
const makeSpendRedeemer = (value: bigint): Data.Data =>
57-
Data.constr(0n, [Data.int(value)])
53+
const makeSpendRedeemer = (value: bigint): Data.Data => Data.constr(0n, [Data.int(value)])
5854

5955
/** MintRedeemer: Constr(0, [entries: List<(Int, Int)>]) */
6056
const makeMintRedeemer = (entries: Array<[bigint, bigint]>): Data.Data =>
61-
Data.constr(0n, [
62-
Data.list(entries.map(([idx, val]) =>
63-
Data.list([Data.int(idx), Data.int(val)])
64-
))
65-
])
57+
Data.constr(0n, [Data.list(entries.map(([idx, val]) => Data.list([Data.int(idx), Data.int(val)])))])
6658

6759
/** CounterDatum: Constr(0, [counter: Int]) */
68-
const makeCounterDatum = (counter: bigint): Data.Data =>
69-
Data.constr(0n, [Data.int(counter)])
60+
const makeCounterDatum = (counter: bigint): Data.Data => Data.constr(0n, [Data.int(counter)])
7061

7162
/** Parse counter value from UTxO inline datum */
7263
const parseCounterDatum = (utxo: Core.UTxO.UTxO): bigint => {
@@ -238,15 +229,15 @@ describe("TxBuilder RedeemerBuilder", () => {
238229
.attachScript({ script: mintMultiScript })
239230
.collectFrom({
240231
inputs: scriptUtxos,
241-
redeemer: ({ index, utxo }: IndexedInput): Data.Data => {
232+
redeemer: ({ index, utxo }: { index: number; utxo: Core.UTxO.UTxO }): Data.Data => {
242233
const datumCounter = parseCounterDatum(utxo)
243234
return makeSpendRedeemer(datumCounter + BigInt(index))
244235
}
245236
})
246237
.mintAssets({
247238
assets: CoreAssets.fromRecord({ [unit]: -300n }),
248239
redeemer: {
249-
all: (inputs: ReadonlyArray<IndexedInput>): Data.Data => {
240+
all: (inputs: ReadonlyArray<{ index: number; utxo: Core.UTxO.UTxO }>): Data.Data => {
250241
const entries: Array<[bigint, bigint]> = inputs.map((input) => {
251242
const datumCounter = parseCounterDatum(input.utxo)
252243
return [BigInt(input.index), datumCounter + BigInt(input.index)]
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/**
2+
* Devnet tests for TxBuilder script stake operations.
3+
*
4+
* Tests the stake_multivalidator coordination pattern:
5+
* - spend: validates input index and requires withdrawal presence
6+
* - withdraw: validates input_indices list, ensures continuity (input_count == output_count)
7+
* - publish: allows registration/delegation/deregistration of script stake credential
8+
*
9+
* Test flow:
10+
* 1. Register the script stake credential (publish)
11+
* 2. Fund UTxOs at the script address
12+
* 3. Build coordination transaction: spend UTxOs + withdraw (0 rewards) + output continuity
13+
* 4. Deregister the script stake credential (publish)
14+
*/
15+
16+
import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"
17+
import * as Cluster from "@evolution-sdk/devnet/Cluster"
18+
import * as Config from "@evolution-sdk/devnet/Config"
19+
import * as Genesis from "@evolution-sdk/devnet/Genesis"
20+
import { Core } from "@evolution-sdk/evolution"
21+
import * as CoreAddress from "@evolution-sdk/evolution/core/Address"
22+
import * as Bytes from "@evolution-sdk/evolution/core/Bytes"
23+
import * as Data from "@evolution-sdk/evolution/core/Data"
24+
import * as DatumOption from "@evolution-sdk/evolution/core/DatumOption"
25+
import * as PlutusV3 from "@evolution-sdk/evolution/core/PlutusV3"
26+
import * as ScriptHash from "@evolution-sdk/evolution/core/ScriptHash"
27+
import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"
28+
29+
import plutusJson from "../../evolution/test/spec/plutus.json"
30+
31+
const getStakeMultiValidator = () => {
32+
const validator = plutusJson.validators.find((v) => v.title === "stake_multivalidator.stake_multivalidator.spend")
33+
34+
if (!validator) {
35+
throw new Error("stake_multivalidator not found in plutus.json")
36+
}
37+
38+
return {
39+
compiledCode: validator.compiledCode,
40+
hash: validator.hash
41+
}
42+
}
43+
44+
const { compiledCode: STAKE_MULTI_COMPILED_CODE, hash: STAKE_MULTI_SCRIPT_HASH } = getStakeMultiValidator()
45+
46+
describe("TxBuilder Script Stake Operations", () => {
47+
let devnetCluster: Cluster.Cluster | undefined
48+
let genesisConfig: Config.ShelleyGenesis
49+
let genesisUtxos: ReadonlyArray<Core.UTxO.UTxO> = []
50+
51+
const TEST_MNEMONIC =
52+
"test test test test test test test test test test test test test test test test test test test test test test test sauce"
53+
54+
// Redeemer types for the stake_multivalidator
55+
/** PublishRedeemer: Constr(0, [placeholder: Int]) */
56+
const makePublishRedeemer = (placeholder: bigint = 0n): Data.Data => Data.constr(0n, [Data.int(placeholder)])
57+
58+
/** WithdrawRedeemer: Constr(0, [input_indices: List<Int>]) */
59+
const makeWithdrawRedeemer = (inputIndices: Array<bigint>): Data.Data =>
60+
Data.constr(0n, [Data.list(inputIndices.map(Data.int))])
61+
62+
/** SpendRedeemer: Int (input index) */
63+
const makeSpendRedeemer = (inputIndex: bigint): Data.Data => Data.int(inputIndex)
64+
65+
const makeStakeMultiScript = (): PlutusV3.PlutusV3 =>
66+
new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(STAKE_MULTI_COMPILED_CODE) })
67+
68+
const stakeScript = makeStakeMultiScript()
69+
const scriptHashValue = ScriptHash.fromScript(stakeScript)
70+
const calculatedScriptHash = ScriptHash.toHex(scriptHashValue)
71+
72+
// Script stake credential for registration/withdrawal
73+
// Use the ScriptHash directly - it's already a valid Credential type
74+
const scriptStakeCredential = scriptHashValue
75+
76+
// Script payment address (for funding UTxOs)
77+
const getScriptPaymentAddress = (): Core.Address.Address => {
78+
return CoreAddress.Address.make({
79+
networkId: 0,
80+
paymentCredential: scriptHashValue
81+
})
82+
}
83+
84+
const createTestClient = (accountIndex: number = 0) =>
85+
createClient({
86+
network: 0,
87+
provider: {
88+
type: "kupmios",
89+
kupoUrl: "http://localhost:1447",
90+
ogmiosUrl: "http://localhost:1342"
91+
},
92+
wallet: {
93+
type: "seed",
94+
mnemonic: TEST_MNEMONIC,
95+
accountIndex,
96+
addressType: "Base" // Need Base address to have stake credential for paying fees
97+
}
98+
})
99+
100+
beforeAll(async () => {
101+
// Verify our script hash calculation matches the blueprint
102+
expect(calculatedScriptHash).toBe(STAKE_MULTI_SCRIPT_HASH)
103+
104+
const testClient = createClient({
105+
network: 0,
106+
wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }
107+
})
108+
109+
const testAddress = await testClient.address()
110+
const testAddressHex = CoreAddress.toHex(testAddress)
111+
112+
genesisConfig = {
113+
...Config.DEFAULT_SHELLEY_GENESIS,
114+
slotLength: 0.02,
115+
epochLength: 50,
116+
activeSlotsCoeff: 1.0,
117+
initialFunds: { [testAddressHex]: 500_000_000_000 }
118+
}
119+
120+
// Pre-calculate genesis UTxOs
121+
genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig)
122+
123+
devnetCluster = await Cluster.make({
124+
clusterName: "script-stake-test",
125+
ports: { node: 6005, submit: 9006 },
126+
shelleyGenesis: genesisConfig,
127+
kupo: { enabled: true, port: 1447, logLevel: "Info" },
128+
ogmios: { enabled: true, port: 1342, logLevel: "info" }
129+
})
130+
131+
await Cluster.start(devnetCluster)
132+
await new Promise((resolve) => setTimeout(resolve, 3_000))
133+
}, 180_000)
134+
135+
afterAll(async () => {
136+
if (devnetCluster) {
137+
await Cluster.stop(devnetCluster)
138+
await Cluster.remove(devnetCluster)
139+
}
140+
}, 60_000)
141+
142+
it("runs full script stake coordination pattern", { timeout: 180_000 }, async () => {
143+
if (genesisUtxos.length === 0) {
144+
throw new Error("Genesis UTxOs not calculated")
145+
}
146+
147+
const client = createTestClient(0)
148+
const scriptPaymentAddress = getScriptPaymentAddress()
149+
150+
// Step 1: Register the script stake credential
151+
152+
let registerTxHash: string | null = null
153+
try {
154+
const registerSignBuilder = await client
155+
.newTx()
156+
.registerStake({
157+
stakeCredential: scriptStakeCredential,
158+
redeemer: makePublishRedeemer(0n)
159+
})
160+
.attachScript({ script: stakeScript })
161+
.build({ availableUtxos: [...genesisUtxos] })
162+
163+
const registerSubmitBuilder = await registerSignBuilder.sign()
164+
registerTxHash = await registerSubmitBuilder.submit()
165+
const registerConfirmed = await client.awaitTx(registerTxHash, 1000)
166+
expect(registerConfirmed).toBe(true)
167+
} catch (e: any) {
168+
// Check if credential is already registered (code 3145)
169+
const errorBody = e?.cause?.cause?.cause?.response?.body
170+
if (!(errorBody?.error?.code === 3145 && errorBody?.error?.message?.includes("re-register"))) {
171+
throw e
172+
}
173+
}
174+
175+
if (registerTxHash) {
176+
await new Promise((resolve) => setTimeout(resolve, 2000))
177+
}
178+
179+
// Step 2: Fund UTxOs at the script address
180+
const unitDatum = new DatumOption.InlineDatum({ data: Data.constr(0n, []) })
181+
182+
const fundSignBuilder = await client
183+
.newTx()
184+
.payToAddress({
185+
address: scriptPaymentAddress,
186+
assets: Core.Assets.fromLovelace(10_000_000n),
187+
datum: unitDatum
188+
})
189+
.payToAddress({
190+
address: scriptPaymentAddress,
191+
assets: Core.Assets.fromLovelace(15_000_000n),
192+
datum: unitDatum
193+
})
194+
.build()
195+
196+
const fundSubmitBuilder = await fundSignBuilder.sign()
197+
const fundTxHash = await fundSubmitBuilder.submit()
198+
const fundConfirmed = await client.awaitTx(fundTxHash, 1000)
199+
expect(fundConfirmed).toBe(true)
200+
201+
await new Promise((resolve) => setTimeout(resolve, 2000))
202+
203+
// Step 3: Coordination transaction (spend + withdraw)
204+
const scriptUtxos = await client.getUtxos(scriptPaymentAddress)
205+
expect(scriptUtxos.length).toBeGreaterThanOrEqual(2)
206+
207+
const utxosToSpend = scriptUtxos.slice(0, 2)
208+
209+
// Build coordination transaction with deferred redeemers
210+
let txBuilder = client.newTx()
211+
212+
// Collect from script UTxOs with self-referencing redeemer
213+
for (const utxo of utxosToSpend) {
214+
txBuilder = txBuilder.collectFrom({
215+
inputs: [utxo],
216+
redeemer: (indexedInput) => makeSpendRedeemer(BigInt(indexedInput.index))
217+
})
218+
}
219+
220+
txBuilder = txBuilder.attachScript({ script: stakeScript })
221+
222+
// Withdraw 0 with batch redeemer referencing all input indices
223+
txBuilder = txBuilder.withdraw({
224+
stakeCredential: scriptStakeCredential,
225+
amount: 0n,
226+
redeemer: {
227+
all: (indexedInputs) => makeWithdrawRedeemer(indexedInputs.map((inp) => BigInt(inp.index))),
228+
inputs: utxosToSpend
229+
}
230+
})
231+
232+
// Output continuity: input_count == output_count
233+
const outputPerUtxo = utxosToSpend.reduce((acc, u) => acc + u.assets.lovelace, 0n) / 2n
234+
235+
txBuilder = txBuilder
236+
.payToAddress({ address: scriptPaymentAddress, assets: Core.Assets.fromLovelace(outputPerUtxo), datum: unitDatum })
237+
.payToAddress({ address: scriptPaymentAddress, assets: Core.Assets.fromLovelace(outputPerUtxo), datum: unitDatum })
238+
239+
const coordSignBuilder = await txBuilder.build()
240+
const coordSubmitBuilder = await coordSignBuilder.sign()
241+
const coordTxHash = await coordSubmitBuilder.submit()
242+
const coordConfirmed = await client.awaitTx(coordTxHash, 1000)
243+
expect(coordConfirmed).toBe(true)
244+
245+
await new Promise((resolve) => setTimeout(resolve, 2000))
246+
247+
// Step 4: Deregister the script stake credential
248+
const deregisterSignBuilder = await client
249+
.newTx()
250+
.deregisterStake({
251+
stakeCredential: scriptStakeCredential,
252+
redeemer: makePublishRedeemer(0n)
253+
})
254+
.attachScript({ script: stakeScript })
255+
.build()
256+
257+
const deregisterSubmitBuilder = await deregisterSignBuilder.sign()
258+
const deregisterTxHash = await deregisterSubmitBuilder.submit()
259+
const deregisterConfirmed = await client.awaitTx(deregisterTxHash, 1000)
260+
expect(deregisterConfirmed).toBe(true)
261+
})
262+
263+
it("captures script failure with labeled redeemers", { timeout: 180_000 }, async () => {
264+
if (genesisUtxos.length === 0) {
265+
throw new Error("Genesis UTxOs not calculated")
266+
}
267+
268+
const client = createTestClient(0)
269+
const scriptPaymentAddress = getScriptPaymentAddress()
270+
271+
// Ensure stake credential is registered
272+
try {
273+
const registerSignBuilder = await client
274+
.newTx()
275+
.registerStake({ stakeCredential: scriptStakeCredential, redeemer: makePublishRedeemer(0n) })
276+
.attachScript({ script: stakeScript })
277+
.build()
278+
const registerSubmitBuilder = await registerSignBuilder.sign()
279+
await client.awaitTx(await registerSubmitBuilder.submit(), 1000)
280+
await new Promise((resolve) => setTimeout(resolve, 2000))
281+
} catch {
282+
// Already registered
283+
}
284+
285+
// Fund a UTxO at script address
286+
const unitDatum = new DatumOption.InlineDatum({ data: Data.constr(0n, []) })
287+
const fundSignBuilder = await client
288+
.newTx()
289+
.payToAddress({ address: scriptPaymentAddress, assets: Core.Assets.fromLovelace(10_000_000n), datum: unitDatum })
290+
.build()
291+
await client.awaitTx(await (await fundSignBuilder.sign()).submit(), 1000)
292+
await new Promise((resolve) => setTimeout(resolve, 2000))
293+
294+
// Get script UTxOs
295+
const scriptUtxos = await client.getUtxos(scriptPaymentAddress)
296+
expect(scriptUtxos.length).toBeGreaterThan(0)
297+
const utxoToSpend = scriptUtxos[0]!
298+
299+
// Build transaction with WRONG redeemers and labels for debugging
300+
const txBuilder = client
301+
.newTx()
302+
.collectFrom({
303+
inputs: [utxoToSpend],
304+
redeemer: makeSpendRedeemer(999n), // Wrong index
305+
label: "coordinator-spend-utxo"
306+
})
307+
.withdraw({
308+
stakeCredential: scriptStakeCredential,
309+
amount: 0n,
310+
redeemer: makeWithdrawRedeemer([999n]), // Wrong indices
311+
label: "coordinator-withdrawal"
312+
})
313+
.payToAddress({
314+
address: scriptPaymentAddress,
315+
assets: Core.Assets.fromLovelace(utxoToSpend.assets.lovelace - 1_000_000n),
316+
datum: unitDatum
317+
})
318+
.attachScript({ script: stakeScript })
319+
320+
// Should fail during evaluation with labeled failures
321+
let capturedError: any = null
322+
try {
323+
await txBuilder.build()
324+
} catch (e: any) {
325+
capturedError = e
326+
}
327+
328+
expect(capturedError).not.toBeNull()
329+
const evalError = capturedError?.cause
330+
expect(evalError?.failures).toBeDefined()
331+
expect(evalError?.failures?.length).toBeGreaterThan(0)
332+
333+
// Verify labels are present in failures
334+
const labels = evalError?.failures?.map((f: any) => f.label)
335+
expect(labels).toContain("coordinator-withdrawal")
336+
})
337+
})

0 commit comments

Comments
 (0)