Skip to content

Commit 930bcb8

Browse files
committed
refactor: devnet modules; better structure; improve devnet name tests
1 parent 9c8a391 commit 930bcb8

File tree

11 files changed

+1415
-1177
lines changed

11 files changed

+1415
-1177
lines changed

packages/evolution-devnet/src/Cluster.ts

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.
File renamed without changes.

packages/evolution-devnet/src/Container.ts

Lines changed: 501 additions & 0 deletions
Large diffs are not rendered by default.

packages/evolution-devnet/src/Devnet.ts

Lines changed: 0 additions & 1057 deletions
This file was deleted.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import * as Address from "@evolution-sdk/evolution/core/AddressEras"
2+
import * as TransactionHash from "@evolution-sdk/evolution/core/TransactionHash"
3+
import * as Assets from "@evolution-sdk/evolution/sdk/Assets"
4+
import type * as UTxO from "@evolution-sdk/evolution/sdk/UTxO"
5+
import { blake2b } from "@noble/hashes/blake2"
6+
import { Data, Effect } from "effect"
7+
8+
import type * as Config from "./Config.js"
9+
import type * as Container from "./Container.js"
10+
11+
export class GenesisError extends Data.TaggedError("GenesisError")<{
12+
reason: string
13+
message: string
14+
cause?: unknown
15+
}> {}
16+
17+
export interface Cluster {
18+
readonly cardanoNode: Container.Container
19+
readonly kupo?: Container.Container | undefined
20+
readonly ogmios?: Container.Container | undefined
21+
readonly networkName: string
22+
}
23+
24+
/**
25+
* Calculate genesis UTxOs deterministically from shelley genesis configuration.
26+
* No node interaction required - purely computational.
27+
*
28+
* Implementation follows Cardano's `initialFundsPseudoTxIn` algorithm:
29+
* Each address gets a unique pseudo-TxId by hashing the address itself (not the genesis JSON).
30+
* This matches the Haskell implementation in cardano-ledger.
31+
*
32+
* Algorithm:
33+
* 1. For each address in initialFunds:
34+
* a. Serialize address to CBOR bytes
35+
* b. Hash with blake2b-256 → this becomes the TxId
36+
* c. Output index is always 0 (minBound)
37+
*
38+
* Reference: cardano-ledger/eras/shelley/impl/src/Cardano/Ledger/Shelley/Genesis.hs
39+
* `initialFundsPseudoTxIn addr = TxIn (pseudoTxId addr) minBound`
40+
* `pseudoTxId = TxId . unsafeMakeSafeHash . Hash.castHash . Hash.hashWith serialiseAddr`
41+
*
42+
* @example
43+
* ```typescript
44+
* import * as Devnet from "@evolution-sdk/evolution-devnet"
45+
*
46+
* const genesisConfig = {
47+
* initialFunds: {
48+
* "00813c32c92aad21...": 900_000_000_000
49+
* },
50+
* // ... other genesis config
51+
* }
52+
*
53+
* const utxos = await Devnet.Genesis.calculateUtxosFromConfigOrThrow(
54+
* genesisConfig
55+
* )
56+
* ```
57+
*
58+
* @since 2.0.0
59+
* @category genesis
60+
*/
61+
export const calculateUtxosFromConfig = (
62+
genesisConfig: Config.ShelleyGenesis
63+
): Effect.Effect<ReadonlyArray<UTxO.UTxO>, GenesisError> =>
64+
Effect.gen(function* () {
65+
const utxos: Array<UTxO.UTxO> = []
66+
const fundEntries = Object.entries(genesisConfig.initialFunds)
67+
68+
for (const [addressHex, lovelace] of fundEntries) {
69+
// Convert hex address to Address object and bech32
70+
const addressBech32 = yield* Effect.try({
71+
try: () => {
72+
const addr = Address.fromHex(addressHex)
73+
return Address.toBech32(addr)
74+
},
75+
catch: (e) =>
76+
new GenesisError({
77+
reason: "address_conversion_failed",
78+
message: `Failed to convert genesis address hex to bech32: ${addressHex}`,
79+
cause: e
80+
})
81+
})
82+
83+
// Calculate pseudo-TxId by hashing the address bytes
84+
// This matches Cardano's: Hash.hashWith serialiseAddr addr
85+
const addr = Address.fromHex(addressHex)
86+
const addressBytes = Address.toBytes(addr)
87+
const txHashBytes = blake2b(addressBytes, { dkLen: 32 })
88+
const txHash = TransactionHash.toHex(new TransactionHash.TransactionHash({ hash: txHashBytes }))
89+
90+
utxos.push({
91+
txHash,
92+
outputIndex: 0, // Genesis UTxOs always use index 0 (minBound in Haskell)
93+
address: addressBech32,
94+
assets: Assets.fromLovelace(BigInt(lovelace))
95+
})
96+
}
97+
98+
return utxos
99+
})
100+
101+
/**
102+
* Calculate genesis UTxOs from config, throws on error.
103+
*
104+
* @since 2.0.0
105+
* @category genesis
106+
*/
107+
export const calculateUtxosFromConfigOrThrow = (genesisConfig: Config.ShelleyGenesis) =>
108+
Effect.runPromise(calculateUtxosFromConfig(genesisConfig))
109+
110+
/**
111+
* Query genesis UTxOs from the running node using cardano-cli.
112+
* This is the "source of truth" method that queries actual chain state.
113+
*
114+
* @since 2.0.0
115+
* @category genesis
116+
*/
117+
export const queryUtxos = (cluster: Cluster): Effect.Effect<ReadonlyArray<UTxO.UTxO>, GenesisError> =>
118+
Effect.gen(function* () {
119+
// Need to import Container functions dynamically to avoid circular dependency
120+
const ContainerModule = yield* Effect.promise(() => import("./Container.js"))
121+
122+
const output = yield* ContainerModule.execCommandEffect(cluster.cardanoNode, [
123+
"cardano-cli",
124+
"conway",
125+
"query",
126+
"utxo",
127+
"--whole-utxo",
128+
"--socket-path",
129+
"/opt/cardano/ipc/node.socket",
130+
"--testnet-magic",
131+
"42",
132+
"--out-file",
133+
"/dev/stdout"
134+
]).pipe(
135+
Effect.mapError(
136+
(e) =>
137+
new GenesisError({
138+
reason: "utxo_query_failed",
139+
message: "Failed to query UTxOs from node",
140+
cause: e
141+
})
142+
)
143+
)
144+
145+
const parsed = yield* Effect.try({
146+
try: () => JSON.parse(output) as Record<string, { address: string; value: { lovelace: number } }>,
147+
catch: (e) =>
148+
new GenesisError({
149+
reason: "utxo_parse_failed",
150+
message: "Failed to parse UTxO query output from cardano-cli",
151+
cause: e
152+
})
153+
})
154+
155+
return Object.entries(parsed).map(([key, data]) => {
156+
const [txHash, outputIndex] = key.split("#")
157+
return {
158+
txHash,
159+
outputIndex: parseInt(outputIndex),
160+
address: data.address,
161+
assets: Assets.fromLovelace(BigInt(data.value.lovelace))
162+
}
163+
})
164+
})
165+
166+
/**
167+
* Query genesis UTxOs from node, throws on error.
168+
*
169+
* @since 2.0.0
170+
* @category genesis
171+
*/
172+
export const queryUtxosOrThrow = (cluster: Cluster) => Effect.runPromise(queryUtxos(cluster))
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import Docker from "dockerode"
2+
import { Data, Effect } from "effect"
3+
4+
export class ImageError extends Data.TaggedError("ImageError")<{
5+
reason: string
6+
message: string
7+
cause?: unknown
8+
}> {}
9+
10+
/**
11+
* Check if a Docker image exists locally.
12+
*
13+
* @since 2.0.0
14+
* @category inspection
15+
*/
16+
export const isAvailableEffect = (imageName: string) =>
17+
Effect.tryPromise({
18+
try: () => {
19+
const docker = new Docker()
20+
return docker.listImages({ filters: { reference: [imageName] } }).then((images) => images.length > 0)
21+
},
22+
catch: (cause) =>
23+
new ImageError({
24+
reason: "image_inspection_failed",
25+
message: `Failed to check if image '${imageName}' is available.`,
26+
cause
27+
})
28+
})
29+
30+
/**
31+
* Check if a Docker image exists locally, throws on error.
32+
*
33+
* @since 2.0.0
34+
* @category inspection
35+
*/
36+
export const isAvailable = (imageName: string) => Effect.runPromise(isAvailableEffect(imageName))
37+
38+
/**
39+
* Pull a Docker image with progress logging.
40+
*
41+
* @since 2.0.0
42+
* @category management
43+
*/
44+
export const pullEffect = (imageName: string) =>
45+
Effect.gen(function* () {
46+
const docker = new Docker()
47+
48+
// eslint-disable-next-line no-console
49+
console.log(`[Devnet] Pulling Docker image: ${imageName}`)
50+
// eslint-disable-next-line no-console
51+
console.log(`[Devnet] This may take a few minutes on first run...`)
52+
53+
const stream = yield* Effect.tryPromise({
54+
try: () => docker.pull(imageName),
55+
catch: (cause) =>
56+
new ImageError({
57+
reason: "image_pull_failed",
58+
message: `Failed to pull image '${imageName}'. Check internet connection and image name.`,
59+
cause
60+
})
61+
})
62+
63+
// Wait for pull to complete
64+
yield* Effect.tryPromise({
65+
try: () =>
66+
new Promise<void>((resolve, reject) => {
67+
docker.modem.followProgress(
68+
stream,
69+
(err: Error | null) => {
70+
if (err) reject(err)
71+
else resolve()
72+
},
73+
(event: { status?: string; progress?: string; id?: string }) => {
74+
// Optional: Log progress
75+
if (event.status === "Downloading" || event.status === "Extracting") {
76+
// Silent progress - only show completion
77+
} else if (event.status) {
78+
// eslint-disable-next-line no-console
79+
console.log(`[Devnet] ${event.status}${event.id ? ` ${event.id}` : ""}`)
80+
}
81+
}
82+
)
83+
}),
84+
catch: (cause) =>
85+
new ImageError({
86+
reason: "image_pull_failed",
87+
message: `Failed to complete image pull for '${imageName}'.`,
88+
cause
89+
})
90+
})
91+
92+
// eslint-disable-next-line no-console
93+
console.log(`[Devnet] ✓ Image ready: ${imageName}`)
94+
})
95+
96+
/**
97+
* Pull a Docker image, throws on error.
98+
*
99+
* @since 2.0.0
100+
* @category management
101+
*/
102+
export const pull = (imageName: string) => Effect.runPromise(pullEffect(imageName))
103+
104+
/**
105+
* Ensure image is available, pull if necessary.
106+
*
107+
* @since 2.0.0
108+
* @category management
109+
*/
110+
export const ensureAvailableEffect = (imageName: string) =>
111+
Effect.gen(function* () {
112+
const available = yield* isAvailableEffect(imageName)
113+
114+
if (!available) {
115+
yield* pullEffect(imageName)
116+
}
117+
})
118+
119+
/**
120+
* Ensure image is available, pull if necessary. Throws on error.
121+
*
122+
* @since 2.0.0
123+
* @category management
124+
*/
125+
export const ensureAvailable = (imageName: string) => Effect.runPromise(ensureAvailableEffect(imageName))
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1-
export * as Devnet from "./Devnet.js"
2-
export * as DevnetDefault from "./DevnetDefault.js"
1+
export * as Cluster from "./Cluster.js"
2+
export * as Config from "./Config.js"
3+
export * as Container from "./Container.js"
4+
export * as Genesis from "./Genesis.js"
5+
export * as Images from "./Images.js"

packages/evolution-devnet/test/Client.Devnet.test.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "@effect/vitest"
2-
import * as Devnet from "@evolution-sdk/devnet/Devnet"
3-
import * as DevnetDefault from "@evolution-sdk/devnet/DevnetDefault"
2+
import * as Cluster from "@evolution-sdk/devnet/Cluster"
3+
import * as Config from "@evolution-sdk/devnet/Config"
4+
import * as Genesis from "@evolution-sdk/devnet/Genesis"
45
import * as Address from "@evolution-sdk/evolution/core/AddressEras"
56
import * as Assets from "@evolution-sdk/evolution/sdk/Assets"
67
import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"
@@ -12,9 +13,9 @@ import { afterAll, beforeAll } from "vitest"
1213
* Client integration tests with local Devnet
1314
*/
1415
describe("Client with Devnet", () => {
15-
let devnetCluster: Devnet.DevNetCluster | undefined
16+
let devnetCluster: Cluster.Cluster | undefined
1617
let genesisUtxos: Array<UTxO> = []
17-
let genesisConfig: DevnetDefault.ShelleyGenesis
18+
let genesisConfig: Config.ShelleyGenesis
1819

1920
const TEST_MNEMONIC =
2021
"test test test test test test test test test test test test test test test test test test test test test test test sauce"
@@ -44,34 +45,34 @@ describe("Client with Devnet", () => {
4445
const testAddressHex = Address.toHex(Address.fromBech32(testAddressBech32))
4546

4647
genesisConfig = {
47-
...DevnetDefault.DEFAULT_SHELLEY_GENESIS,
48+
...Config.DEFAULT_SHELLEY_GENESIS,
4849
slotLength: 0.02,
4950
epochLength: 50,
5051
activeSlotsCoeff: 1.0,
5152
initialFunds: { [testAddressHex]: 900_000_000_000 }
5253
}
5354

54-
devnetCluster = await Devnet.Cluster.make({
55-
clusterName: "client-devnet-test",
55+
devnetCluster = await Cluster.make({
56+
clusterName: "client-kupmios-wallet-test",
5657
ports: { node: 6001, submit: 9002 },
5758
shelleyGenesis: genesisConfig,
5859
kupo: { enabled: true, port: 1443, logLevel: "Info" },
5960
ogmios: { enabled: true, port: 1338, logLevel: "info" }
6061
})
6162

62-
await Devnet.Cluster.start(devnetCluster)
63+
await Cluster.start(devnetCluster)
6364
await new Promise((resolve) => setTimeout(resolve, 3_000))
6465
}, 180_000)
6566

6667
afterAll(async () => {
6768
if (devnetCluster) {
68-
await Devnet.Cluster.stop(devnetCluster)
69-
await Devnet.Cluster.remove(devnetCluster)
69+
await Cluster.stop(devnetCluster)
70+
await Cluster.remove(devnetCluster)
7071
}
7172
}, 60_000)
7273

7374
it("should calculate genesis UTxOs from config", { timeout: 10_000 }, async () => {
74-
const calculatedUtxos = await Devnet.Genesis.calculateUtxosFromConfigOrThrow(genesisConfig)
75+
const calculatedUtxos = await Genesis.calculateUtxosFromConfigOrThrow(genesisConfig)
7576

7677
expect(calculatedUtxos).toBeDefined()
7778
expect(calculatedUtxos.length).toBe(1)

0 commit comments

Comments
 (0)