Skip to content

Commit 0730f23

Browse files
committed
feat: add tx validity module
1 parent 8c9dc71 commit 0730f23

File tree

10 files changed

+553
-42
lines changed

10 files changed

+553
-42
lines changed

.changeset/long-deer-roll.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@evolution-sdk/devnet": patch
3+
"@evolution-sdk/evolution": patch
4+
---
5+
6+
### TxBuilder setValidity API
7+
8+
Add `setValidity()` method to TxBuilder for setting transaction validity intervals:
9+
10+
```ts
11+
client.newTx()
12+
.setValidity({
13+
from: Date.now(), // Valid after this Unix time (optional)
14+
to: Date.now() + 300_000 // Expires after this Unix time (optional)
15+
})
16+
.payToAddress({ ... })
17+
.build()
18+
```
19+
20+
- Times are provided as Unix milliseconds and converted to slots during transaction assembly
21+
- At least one of `from` or `to` must be specified
22+
- Validates that `from < to` when both are provided
23+
24+
### slotConfig support for devnets
25+
26+
Add `slotConfig` parameter to `createClient()` for custom slot configurations:
27+
28+
```ts
29+
const slotConfig = Cluster.getSlotConfig(devnetCluster)
30+
const client = createClient({
31+
network: 0,
32+
slotConfig, // Custom slot config for devnet
33+
provider: { ... },
34+
wallet: { ... }
35+
})
36+
```
37+
38+
Priority chain for slot config resolution:
39+
1. `BuildOptions.slotConfig` (per-transaction override)
40+
2. `TxBuilderConfig.slotConfig` (client default)
41+
3. `SLOT_CONFIG_NETWORK[network]` (hardcoded fallback)
42+
43+
### Cluster.getSlotConfig helper
44+
45+
Add `getSlotConfig()` helper to derive slot configuration from devnet cluster genesis:
46+
47+
```ts
48+
const slotConfig = Cluster.getSlotConfig(cluster)
49+
// Returns: { zeroTime, zeroSlot, slotLength }
50+
```

packages/evolution-devnet/src/Cluster.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface Cluster {
2020
readonly kupo?: Container.Container | undefined
2121
readonly ogmios?: Container.Container | undefined
2222
readonly networkName: string
23+
/** The Shelley genesis config used by this cluster (needed for slot config) */
24+
readonly shelleyGenesis: Config.ShelleyGenesis
2325
}
2426

2527
/**
@@ -288,7 +290,8 @@ export const makeEffect = (config: Config.DevNetConfig = {}): Effect.Effect<Clus
288290
name: `${fullConfig.clusterName}-ogmios`
289291
}
290292
: undefined,
291-
networkName
293+
networkName,
294+
shelleyGenesis: fullConfig.shelleyGenesis as Config.ShelleyGenesis
292295
}
293296
})
294297

@@ -489,3 +492,58 @@ export const removeEffect = (cluster: Cluster): Effect.Effect<void, ClusterError
489492
* @category lifecycle
490493
*/
491494
export const remove = (cluster: Cluster) => Effect.runPromise(removeEffect(cluster))
495+
496+
/**
497+
* Slot configuration type for Unix time to slot conversion.
498+
*
499+
* @since 2.0.0
500+
* @category model
501+
*/
502+
export interface SlotConfig {
503+
readonly zeroTime: bigint
504+
readonly zeroSlot: bigint
505+
readonly slotLength: number
506+
}
507+
508+
/**
509+
* Extract slot configuration from a devnet cluster.
510+
*
511+
* This returns the slot config needed for converting Unix timestamps to slots
512+
* when using setValidity() or other time-based transaction operations.
513+
*
514+
* The slot config is derived from the cluster's Shelley genesis:
515+
* - zeroTime: Genesis system start time (in milliseconds)
516+
* - zeroSlot: Always 0 for devnets
517+
* - slotLength: Slot duration in milliseconds
518+
*
519+
* @example
520+
* ```typescript
521+
* import * as Cluster from "@evolution-sdk/devnet/Cluster"
522+
* import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"
523+
*
524+
* const cluster = await Cluster.make({ ... })
525+
* const slotConfig = Cluster.getSlotConfig(cluster)
526+
*
527+
* const client = createClient({
528+
* network: 0,
529+
* slotConfig,
530+
* provider: { type: "kupmios", kupoUrl: "...", ogmiosUrl: "..." },
531+
* wallet: { type: "seed", mnemonic: "..." }
532+
* })
533+
* ```
534+
*
535+
* @since 2.0.0
536+
* @category utilities
537+
*/
538+
export const getSlotConfig = (cluster: Cluster): SlotConfig => {
539+
const genesis = cluster.shelleyGenesis
540+
// systemStart is ISO string, convert to Unix ms
541+
const zeroTime = BigInt(new Date(genesis.systemStart).getTime())
542+
// slotLength in genesis is in seconds, convert to ms
543+
const slotLength = genesis.slotLength * 1000
544+
return {
545+
zeroTime,
546+
zeroSlot: 0n,
547+
slotLength
548+
}
549+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Devnet tests for TxBuilder validity interval (setValidity).
3+
*
4+
* Tests the setValidity operation which sets transaction validity bounds:
5+
* - `from`: Transaction valid after this Unix time (validityIntervalStart slot)
6+
* - `to`: Transaction expires after this Unix time (ttl slot)
7+
*
8+
* Test scenarios:
9+
* 1. Build and submit a transaction with only TTL (to)
10+
* 2. Build and submit a transaction with both bounds (from + to)
11+
* 3. Verify expired transaction is rejected
12+
*/
13+
14+
import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"
15+
import * as Cluster from "@evolution-sdk/devnet/Cluster"
16+
import * as Config from "@evolution-sdk/devnet/Config"
17+
import * as Genesis from "@evolution-sdk/devnet/Genesis"
18+
import { Core } from "@evolution-sdk/evolution"
19+
import * as Address from "@evolution-sdk/evolution/core/Address"
20+
import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"
21+
22+
// Alias for readability
23+
const Time = Core.Time
24+
25+
describe("TxBuilder Validity Interval", () => {
26+
let devnetCluster: Cluster.Cluster | undefined
27+
let genesisConfig: Config.ShelleyGenesis
28+
let genesisUtxos: ReadonlyArray<Core.UTxO.UTxO> = []
29+
30+
const TEST_MNEMONIC =
31+
"test test test test test test test test test test test test test test test test test test test test test test test sauce"
32+
33+
// Creates a client with correct slot config for devnet
34+
const createTestClient = (accountIndex: number = 0) => {
35+
if (!devnetCluster) throw new Error("Cluster not initialized")
36+
const slotConfig = Cluster.getSlotConfig(devnetCluster)
37+
return createClient({
38+
network: 0,
39+
slotConfig,
40+
provider: {
41+
type: "kupmios",
42+
kupoUrl: "http://localhost:1448",
43+
ogmiosUrl: "http://localhost:1343"
44+
},
45+
wallet: {
46+
type: "seed",
47+
mnemonic: TEST_MNEMONIC,
48+
accountIndex,
49+
addressType: "Base"
50+
}
51+
})
52+
}
53+
54+
beforeAll(async () => {
55+
// Create a minimal client just to get the address (before cluster is ready)
56+
const tempClient = createClient({
57+
network: 0,
58+
wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }
59+
})
60+
61+
const testAddress = await tempClient.address()
62+
const testAddressHex = Address.toHex(testAddress)
63+
64+
genesisConfig = {
65+
...Config.DEFAULT_SHELLEY_GENESIS,
66+
slotLength: 0.02,
67+
epochLength: 50,
68+
activeSlotsCoeff: 1.0,
69+
initialFunds: { [testAddressHex]: 500_000_000_000 }
70+
}
71+
72+
genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig)
73+
74+
devnetCluster = await Cluster.make({
75+
clusterName: "validity-test",
76+
ports: { node: 6006, submit: 9007 },
77+
shelleyGenesis: genesisConfig,
78+
kupo: { enabled: true, port: 1448, logLevel: "Info" },
79+
ogmios: { enabled: true, port: 1343, logLevel: "info" }
80+
})
81+
82+
await Cluster.start(devnetCluster)
83+
await new Promise((resolve) => setTimeout(resolve, 3_000))
84+
}, 180_000)
85+
86+
afterAll(async () => {
87+
if (devnetCluster) {
88+
await Cluster.stop(devnetCluster)
89+
await Cluster.remove(devnetCluster)
90+
}
91+
}, 60_000)
92+
93+
it("should build transaction with TTL and convert to slot correctly", { timeout: 60_000 }, async () => {
94+
const client = createTestClient(0)
95+
const myAddress = await client.address()
96+
97+
// Set TTL to 5 minutes from now
98+
const ttl = Time.now() + 300_000n
99+
100+
const signBuilder = await client
101+
.newTx()
102+
.setValidity({ to: ttl })
103+
.payToAddress({
104+
address: myAddress,
105+
assets: Core.Assets.fromLovelace(5_000_000n)
106+
})
107+
.build({ availableUtxos: [...genesisUtxos], debug: true })
108+
109+
const tx = await signBuilder.toTransaction()
110+
111+
// Verify TTL is set in transaction body and converted to a slot number
112+
expect(tx.body.ttl).toBeDefined()
113+
expect(typeof tx.body.ttl).toBe("bigint")
114+
expect(tx.body.ttl! > 0n).toBe(true)
115+
expect(tx.body.validityIntervalStart).toBeUndefined()
116+
})
117+
118+
it("should build transaction with both validity bounds and convert to slots", { timeout: 60_000 }, async () => {
119+
const client = createTestClient(0)
120+
const myAddress = await client.address()
121+
122+
// Valid from now until 5 minutes from now
123+
const from = Time.now()
124+
const to = Time.now() + 300_000n
125+
126+
const signBuilder = await client
127+
.newTx()
128+
.setValidity({ from, to })
129+
.payToAddress({
130+
address: myAddress,
131+
assets: Core.Assets.fromLovelace(5_000_000n)
132+
})
133+
.build({ availableUtxos: [...genesisUtxos], debug: true })
134+
135+
const tx = await signBuilder.toTransaction()
136+
137+
// Verify both bounds are set as slot numbers
138+
expect(tx.body.ttl).toBeDefined()
139+
expect(typeof tx.body.ttl).toBe("bigint")
140+
expect(tx.body.ttl! > 0n).toBe(true)
141+
142+
expect(tx.body.validityIntervalStart).toBeDefined()
143+
expect(typeof tx.body.validityIntervalStart).toBe("bigint")
144+
expect(tx.body.validityIntervalStart! > 0n).toBe(true)
145+
146+
// TTL should be after validity start
147+
expect(tx.body.ttl! > tx.body.validityIntervalStart!).toBe(true)
148+
})
149+
150+
it("should reject expired transaction", { timeout: 60_000 }, async () => {
151+
const client = createTestClient(0)
152+
const myAddress = await client.address()
153+
154+
// Set TTL to 1 second ago (already expired)
155+
const expiredTtl = Time.now() - 1_000n
156+
157+
const signBuilder = await client
158+
.newTx()
159+
.setValidity({ to: expiredTtl })
160+
.payToAddress({
161+
address: myAddress,
162+
assets: Core.Assets.fromLovelace(5_000_000n)
163+
})
164+
.build({ availableUtxos: [...genesisUtxos], debug: true })
165+
166+
const submitBuilder = await signBuilder.sign()
167+
168+
// Submission should fail due to expired TTL
169+
await expect(submitBuilder.submit()).rejects.toThrow()
170+
})
171+
172+
it("should reject transaction before validity start", { timeout: 60_000 }, async () => {
173+
const client = createTestClient(0)
174+
const myAddress = await client.address()
175+
176+
// Valid starting 5 minutes from now (not valid yet)
177+
const from = Time.now() + 300_000n
178+
const to = Time.now() + 600_000n
179+
180+
const signBuilder = await client
181+
.newTx()
182+
.setValidity({ from, to })
183+
.payToAddress({
184+
address: myAddress,
185+
assets: Core.Assets.fromLovelace(5_000_000n)
186+
})
187+
.build({ availableUtxos: [...genesisUtxos], debug: true })
188+
189+
const submitBuilder = await signBuilder.sign()
190+
191+
// Submission should fail because tx is not valid yet
192+
await expect(submitBuilder.submit()).rejects.toThrow()
193+
})
194+
})

0 commit comments

Comments
 (0)