|
| 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)) |
0 commit comments