diff --git a/.gitignore b/.gitignore index 4cbf34ed..27fb89d3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ docs/out docs/.next # TypeScript incremental compilation cache *.tsbuildinfo -.tsbuildinfo/ \ No newline at end of file +.tsbuildinfo/ + +.github/instructions/ diff --git a/flake.nix b/flake.nix index 01cab61c..fed34abc 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ devShells = forEachSupportedSystem ({ pkgs }: { default = pkgs.mkShell { - packages = with pkgs; [ nodejs nodePackages.pnpm ]; + packages = with pkgs; [ nodejs nodePackages.pnpm bun python3 ]; }; }); }; diff --git a/package.json b/package.json index 6be2cf0d..2c901001 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "madge": "^8.0.0", "prettier": "^3.5.0", "rimraf": "^6.0.0", + "tsx": "^4.20.3", "turbo": "^2.0.0", "typescript": "^5.8.0", "vitest": "^3.2.4" diff --git a/packages/evolution/package.json b/packages/evolution/package.json index c87ee113..19dec8db 100644 --- a/packages/evolution/package.json +++ b/packages/evolution/package.json @@ -37,16 +37,23 @@ "clean": "rm -rf dist .turbo docs" }, "devDependencies": { + "@dcspark/cardano-multiplatform-lib-nodejs": "^6.2.0", "@types/dockerode": "^3.3.0", + "@types/libsodium-wrappers-sumo": "^0.7.8", "tsx": "^4.20.3", "typescript": "^5.4.0" }, "dependencies": { - "@effect/platform": "^0.90.0", - "@effect/platform-node": "^0.94.0", - "@scure/base": "^1.1.0", + "@effect/platform-node": "^0.94.1", + "@noble/hashes": "^1.6.0", + "@scure/base": "^1.2.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "@types/bip39": "^3.0.4", + "bip39": "^3.1.0", "dockerode": "^4.0.0", - "effect": "^3.17.3" + "effect": "^3.17.3", + "libsodium-wrappers-sumo": "^0.7.15" }, "keywords": [ "cardano", diff --git a/packages/evolution/src/Address.ts b/packages/evolution/src/Address.ts index 04e6578e..b260b684 100644 --- a/packages/evolution/src/Address.ts +++ b/packages/evolution/src/Address.ts @@ -1,10 +1,9 @@ -import { Data, Effect, FastCheck, ParseResult, pipe, Schema } from "effect" +import { bech32 } from "@scure/base" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as BaseAddress from "./BaseAddress.js" -import * as Bech32 from "./Bech32.js" import * as ByronAddress from "./ByronAddress.js" import * as Bytes from "./Bytes.js" -import { createEncoders } from "./Codec.js" import * as EnterpriseAddress from "./EnterpriseAddress.js" import * as PointerAddress from "./PointerAddress.js" import * as RewardAccount from "./RewardAccount.js" @@ -99,7 +98,7 @@ export const FromBytes = Schema.transformOrFail(Schema.Uint8ArrayFromSelf, Addre } }, decode: (_, __, ast, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const header = fromA[0] // Extract address type from the upper 4 bits (bits 4-7) const addressType = header >> 4 @@ -152,10 +151,10 @@ export const FromHex = Schema.compose(Bytes.FromHex, FromBytes) * @since 2.0.0 * @category schema */ -export const FromBech32 = Schema.transformOrFail(Schema.typeSchema(Bech32.Bech32Schema), Address, { +export const FromBech32 = Schema.transformOrFail(Schema.String, Address, { strict: true, - encode: (_, __, ___, toA) => - Effect.gen(function* () { + encode: (_, __, ast, toA) => + Eff.gen(function* () { const bytes = yield* ParseResult.encode(FromBytes)(toA) let prefix: string switch (toA._tag) { @@ -168,13 +167,34 @@ export const FromBech32 = Schema.transformOrFail(Schema.typeSchema(Bech32.Bech32 prefix = toA.networkId === 0 ? "stake_test" : "stake" break case "ByronAddress": - prefix = "" - break + return yield* ParseResult.fail( + new ParseResult.Type(ast, toA, "Byron addresses do not support Bech32 encoding") + ) } - const b = yield* ParseResult.decode(Bech32.FromBytes(prefix))(bytes) - return b + const result = yield* Eff.try({ + try: () => { + const words = bech32.toWords(bytes) + return bech32.encode(prefix, words, false) + }, + catch: (error) => new ParseResult.Type(ast, toA, `Failed to encode Bech32: ${(error as Error).message}`) + }) + return result }), - decode: (fromI) => pipe(ParseResult.encode(Bech32.FromBytes())(fromI), Effect.flatMap(ParseResult.decode(FromBytes))) + decode: (fromA, _, ast) => + Eff.gen(function* () { + const result = yield* Eff.try({ + try: () => { + const decoded = bech32.decode(fromA as any, false) + const bytes = bech32.fromWords(decoded.words) + return new Uint8Array(bytes) + }, + catch: (error) => new ParseResult.Type(ast, fromA, `Failed to decode Bech32: ${(error as Error).message}`) + }) + return yield* ParseResult.decode(FromBytes)(result) + }) +}).annotations({ + identifier: "Address.FromBech32", + description: "Transforms Bech32 string to Address" }) /** @@ -202,29 +222,156 @@ export const equals = (a: Address, b: Address): boolean => { } /** - * FastCheck generator for addresses. + * FastCheck arbitrary for Address instances. * * @since 2.0.0 - * @category testing + * @category arbitrary + * */ -export const generator = FastCheck.oneof( - BaseAddress.generator, - EnterpriseAddress.generator, - PointerAddress.generator, - RewardAccount.generator +export const arbitrary = FastCheck.oneof( + BaseAddress.arbitrary, + EnterpriseAddress.arbitrary, + PointerAddress.arbitrary, + RewardAccount.arbitrary ) +// ============================================================================ +// Parsing Functions +// ============================================================================ + /** - * Codec utilities for addresses. + * Parse an Address from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bech32: FromBech32, - hex: FromHex, - bytes: FromBytes - }, - AddressError -) +export const fromBytes = (bytes: Uint8Array): Address => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse an Address from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): Address => Eff.runSync(Effect.fromHex(hex)) + +/** + * Parse an Address from Bech32 string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBech32 = (bech32: string): Address => Eff.runSync(Effect.fromBech32(bech32)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert an Address to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (address: Address): Uint8Array => Eff.runSync(Effect.toBytes(address)) + +/** + * Convert an Address to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (address: Address): string => Eff.runSync(Effect.toHex(address)) + +/** + * Convert an Address to Bech32 string. + * + * @since 2.0.0 + * @category encoding + */ +export const toBech32 = (address: Address): string => Eff.runSync(Effect.toBech32(address)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse an Address from bytes. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array) => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (error) => new AddressError({ message: "Failed to decode Address from bytes", cause: error }) + ) + + /** + * Parse an Address from hex string. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string) => + Eff.mapError( + Schema.decode(FromHex)(hex), + (error) => new AddressError({ message: "Failed to decode Address from hex", cause: error }) + ) + + /** + * Parse an Address from Bech32 string. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBech32 = (bech32: string) => + Eff.mapError( + Schema.decode(FromBech32)(bech32), + (error) => new AddressError({ message: "Failed to decode Address from Bech32", cause: error }) + ) + + /** + * Convert an Address to bytes. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (address: Address) => + Eff.mapError( + Schema.encode(FromBytes)(address), + (error) => new AddressError({ message: "Failed to encode Address to bytes", cause: error }) + ) + + /** + * Convert an Address to hex string. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (address: Address) => + Eff.mapError( + Schema.encode(FromHex)(address), + (error) => new AddressError({ message: "Failed to encode Address to hex", cause: error }) + ) + + /** + * Convert an Address to Bech32 string. + * + * @since 2.0.0 + * @category encoding + */ + export const toBech32 = (address: Address) => + Eff.mapError( + Schema.encode(FromBech32)(address), + (error) => new AddressError({ message: "Failed to encode Address to Bech32", cause: error }) + ) +} diff --git a/packages/evolution/src/AddressDetails.ts b/packages/evolution/src/AddressDetails.ts index a9b40e73..3db04b76 100644 --- a/packages/evolution/src/AddressDetails.ts +++ b/packages/evolution/src/AddressDetails.ts @@ -1,9 +1,7 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, ParseResult, Schema } from "effect" import * as Address from "./Address.js" -import * as _Bech32 from "./Bech32.js" import * as Bytes from "./Bytes.js" -import * as _Codec from "./Codec.js" import * as NetworkId from "./NetworkId.js" export class AddressDetailsError extends Data.TaggedError("AddressDetailsError")<{ @@ -12,20 +10,13 @@ export class AddressDetailsError extends Data.TaggedError("AddressDetailsError") }> {} /** - * Extended address information with both structured data and serialized formats + * Schema for AddressDetails representing extended address information. * Contains the address structure and its serialized representations * * @since 2.0.0 - * @category model - */ - -/** - * Pointer address with payment credential and pointer to stake registration - * - * @since 2.0.0 * @category schemas */ -export class AddressDetails extends Schema.TaggedClass("AddressDetails")("AddressDetails", { +export class AddressDetails extends Schema.Class("AddressDetails")({ networkId: NetworkId.NetworkId, type: Schema.Union( Schema.Literal("BaseAddress"), @@ -35,15 +26,15 @@ export class AddressDetails extends Schema.TaggedClass("AddressD Schema.Literal("ByronAddress") ), address: Address.Address, - bech32: _Bech32.Bech32Schema, + bech32: Schema.String, hex: Bytes.HexSchema }) {} -export const FromBech32 = Schema.transformOrFail(Schema.typeSchema(_Bech32.Bech32Schema), AddressDetails, { +export const FromBech32 = Schema.transformOrFail(Schema.String, AddressDetails, { strict: true, encode: (_, __, ___, toA) => ParseResult.succeed(toA.bech32), decode: (_, __, ___, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const address = yield* ParseResult.decode(Address.FromBech32)(fromA) const hex = yield* ParseResult.encode(Address.FromHex)(address) return new AddressDetails({ @@ -60,7 +51,7 @@ export const FromHex = Schema.transformOrFail(Bytes.HexSchema, AddressDetails, { strict: true, encode: (_, __, ___, toA) => ParseResult.succeed(toA.hex), decode: (_, __, ___, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const address = yield* ParseResult.decode(Address.FromHex)(fromA) const bech32 = yield* ParseResult.encode(Address.FromBech32)(address) return new AddressDetails({ @@ -73,10 +64,154 @@ export const FromHex = Schema.transformOrFail(Bytes.HexSchema, AddressDetails, { }) }) -export const Codec = _Codec.createEncoders( - { - bech32: FromBech32, - hex: FromHex - }, - AddressDetailsError -) +/** + * Create AddressDetails from an Address instance. + * + * @since 2.0.0 + * @category constructors + */ +export const make = AddressDetails.make + +/** + * Check if two AddressDetails instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (self: AddressDetails, that: AddressDetails): boolean => { + return ( + self.networkId === that.networkId && + self.type === that.type && + Address.equals(self.address, that.address) && + self.bech32 === that.bech32 && + self.hex === that.hex + ) +} + +/** + * FastCheck arbitrary for AddressDetails instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = Address.arbitrary.map((address) => fromAddress(address)) + +/** + * Create AddressDetails from an Address. + * + * @since 2.0.0 + * @category constructors + */ +export const fromAddress = (address: Address.Address): AddressDetails => { + // Use schema encoding to get the serialized formats + const bech32 = Eff.runSync(Schema.encode(Address.FromBech32)(address)) + const hex = Eff.runSync(Schema.encode(Address.FromHex)(address)) + return new AddressDetails({ + networkId: address.networkId, + type: address._tag, + address, + bech32, + hex + }) +} + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse AddressDetails from Bech32 string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBech32 = (bech32: string): AddressDetails => Eff.runSync(Effect.fromBech32(bech32)) + +/** + * Parse AddressDetails from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): AddressDetails => Eff.runSync(Effect.fromHex(hex)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert AddressDetails to Bech32 string. + * + * @since 2.0.0 + * @category encoding + */ +export const toBech32 = (details: AddressDetails): string => Eff.runSync(Effect.toBech32(details)) + +/** + * Convert AddressDetails to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (details: AddressDetails): string => Eff.runSync(Effect.toHex(details)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse AddressDetails from Bech32 string. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBech32 = (bech32: string) => + Eff.mapError( + Schema.decode(FromBech32)(bech32), + (error) => new AddressDetailsError({ message: "Failed to decode AddressDetails from Bech32", cause: error }) + ) + + /** + * Parse AddressDetails from hex string. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string) => + Eff.mapError( + Schema.decode(FromHex)(hex), + (error) => new AddressDetailsError({ message: "Failed to decode AddressDetails from hex", cause: error }) + ) + + /** + * Convert AddressDetails to Bech32 string. + * + * @since 2.0.0 + * @category encoding + */ + export const toBech32 = (details: AddressDetails) => + Eff.mapError( + Schema.encode(FromBech32)(details), + (error) => new AddressDetailsError({ message: "Failed to encode AddressDetails to Bech32", cause: error }) + ) + + /** + * Convert AddressDetails to hex string. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (details: AddressDetails) => + Eff.mapError( + Schema.encode(FromHex)(details), + (error) => new AddressDetailsError({ message: "Failed to encode AddressDetails to hex", cause: error }) + ) +} diff --git a/packages/evolution/src/Anchor.ts b/packages/evolution/src/Anchor.ts index 69819865..205022ec 100644 --- a/packages/evolution/src/Anchor.ts +++ b/packages/evolution/src/Anchor.ts @@ -1,9 +1,8 @@ -import { Data, Effect, FastCheck, ParseResult, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, pipe, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as Bytes32 from "./Bytes32.js" import * as CBOR from "./CBOR.js" -import { createEncoders } from "./Codec.js" import * as Url from "./Url.js" /** @@ -14,7 +13,7 @@ import * as Url from "./Url.js" */ export class AnchorError extends Data.TaggedError("AnchorError")<{ message?: string - reason?: "InvalidStructure" | "InvalidUrl" | "InvalidHash" + cause?: unknown }> {} /** @@ -22,13 +21,18 @@ export class AnchorError extends Data.TaggedError("AnchorError")<{ * anchor = [anchor_url: url, anchor_data_hash: Bytes32] * * @since 2.0.0 - * @category model + * @category schemas */ -export class Anchor extends Schema.TaggedClass()("Anchor", { +export class Anchor extends Schema.Class("Anchor")({ anchorUrl: Url.Url, anchorDataHash: Bytes32.HexSchema }) {} +export const CDDLSchema = Schema.Tuple( + CBOR.Text, // anchor_url: url + CBOR.ByteArray // anchor_data_hash: Bytes32 +) + /** * CDDL schema for Anchor as tuple structure. * anchor = [anchor_url: url, anchor_data_hash: Bytes32] @@ -36,17 +40,17 @@ export class Anchor extends Schema.TaggedClass()("Anchor", { * @since 2.0.0 * @category schemas */ -export const FromCDDL = Schema.transformOrFail(Schema.Tuple(CBOR.Text, CBOR.ByteArray), Schema.typeSchema(Anchor), { +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Anchor), { strict: true, encode: (toA) => pipe( ParseResult.encode(Bytes32.FromBytes)(toA.anchorDataHash), - Effect.map((anchorDataHash) => [toA.anchorUrl, anchorDataHash] as const) + Eff.map((anchorDataHash) => [toA.anchorUrl, anchorDataHash] as const) ), decode: ([anchorUrl, anchorDataHash]) => pipe( ParseResult.decode(Bytes32.FromBytes)(anchorDataHash), - Effect.map( + Eff.map( (anchorDataHash) => new Anchor({ anchorUrl: Url.make(anchorUrl), @@ -62,7 +66,7 @@ export const FromCDDL = Schema.transformOrFail(Schema.Tuple(CBOR.Text, CBOR.Byte * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → Anchor @@ -74,10 +78,10 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → Anchor + FromCBORBytes(options) // Uint8Array → Anchor ) /** @@ -108,27 +112,126 @@ export const equals = (self: Anchor, that: Anchor): boolean => { } /** - * FastCheck generator for Anchor instances. + * FastCheck arbitrary for Anchor instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.record({ - anchorUrl: Url.generator, - anchorDataHash: FastCheck.uint8Array({ minLength: 32, maxLength: 32 }) +export const arbitrary = FastCheck.record({ + anchorUrl: Url.arbitrary, + anchorDataHash: FastCheck.hexaString({ + minLength: Bytes32.HEX_LENGTH, + maxLength: Bytes32.HEX_LENGTH + }) }).map( ({ anchorDataHash, anchorUrl }) => new Anchor({ anchorUrl, - anchorDataHash: Bytes.Codec.Decode.bytes(anchorDataHash) + anchorDataHash }) ) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - AnchorError - ) +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse an Anchor from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Anchor => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse an Anchor from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Anchor => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert an Anchor to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (value: Anchor, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(value, options)) + +/** + * Convert an Anchor to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (value: Anchor, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(value, options)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse an Anchor from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (error) => new AnchorError({ message: "Failed to decode Anchor from CBOR bytes", cause: error }) + ) + + /** + * Parse an Anchor from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (error) => new AnchorError({ message: "Failed to decode Anchor from CBOR hex", cause: error }) + ) + + /** + * Convert an Anchor to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = (value: Anchor, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(value), + (error) => new AnchorError({ message: "Failed to encode Anchor to CBOR bytes", cause: error }) + ) + + /** + * Convert an Anchor to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = (value: Anchor, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(value), + (error) => new AnchorError({ message: "Failed to encode Anchor to CBOR hex", cause: error }) + ) +} diff --git a/packages/evolution/src/AssetName.ts b/packages/evolution/src/AssetName.ts index 0a67ffbb..b4f3c943 100644 --- a/packages/evolution/src/AssetName.ts +++ b/packages/evolution/src/AssetName.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes32 from "./Bytes32.js" -import { createEncoders } from "./Codec.js" /** * Error class for AssetName related operations. @@ -47,6 +46,14 @@ export const FromHex = Schema.compose(Bytes32.VariableHexSchema, AssetName).anno identifier: "AssetName.Hex" }) +/** + * Smart constructor for AssetName that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = AssetName.make + /** * Check if two AssetName instances are equal. * @@ -56,26 +63,136 @@ export const FromHex = Schema.compose(Bytes32.VariableHexSchema, AssetName).anno export const equals = (a: AssetName, b: AssetName): boolean => a === b /** - * Generate a random AssetName. + * Check if the given value is a valid AssetName + * + * @since 2.0.0 + * @category predicates + */ +export const isAssetName = Schema.is(AssetName) + +/** + * FastCheck arbitrary for generating random AssetName instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ +export const arbitrary = FastCheck.hexaString({ minLength: 0, - maxLength: Bytes32.Bytes32_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) + maxLength: Bytes32.HEX_LENGTH +}).map((hex) => fromHex(hex)) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for AssetName encoding and decoding operations. + * Parse AssetName from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - AssetNameError -) +export const fromBytes = (bytes: Uint8Array): AssetName => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse AssetName from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): AssetName => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode AssetName to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (assetName: AssetName): Uint8Array => Eff.runSync(Effect.toBytes(assetName)) + +/** + * Encode AssetName to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (assetName: AssetName): string => Eff.runSync(Effect.toHex(assetName)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse AssetName from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new AssetNameError({ + message: "Failed to parse AssetName from bytes", + cause + }) + ) + ) + + /** + * Parse AssetName from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new AssetNameError({ + message: "Failed to parse AssetName from hex", + cause + }) + ) + ) + + /** + * Encode AssetName to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (assetName: AssetName): Eff.Effect => + Schema.encode(FromBytes)(assetName).pipe( + Eff.mapError( + (cause) => + new AssetNameError({ + message: "Failed to encode AssetName to bytes", + cause + }) + ) + ) + + /** + * Encode AssetName to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (assetName: AssetName): Eff.Effect => + Schema.encode(FromHex)(assetName).pipe( + Eff.mapError( + (cause) => + new AssetNameError({ + message: "Failed to encode AssetName to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/AuxiliaryData.ts b/packages/evolution/src/AuxiliaryData.ts new file mode 100644 index 00000000..0f3b2625 --- /dev/null +++ b/packages/evolution/src/AuxiliaryData.ts @@ -0,0 +1,338 @@ +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" + +import * as Bytes from "./Bytes.js" +import * as CBOR from "./CBOR.js" +import * as Metadata from "./Metadata.js" +import * as NativeScripts from "./NativeScripts.js" +import * as PlutusV1 from "./PlutusV1.js" +import * as PlutusV2 from "./PlutusV2.js" +import * as PlutusV3 from "./PlutusV3.js" + +/** + * Error class for AuxiliaryData related operations. + * + * @since 2.0.0 + * @category errors + */ +export class AuxiliaryDataError extends Data.TaggedError("AuxiliaryDataError")<{ + message?: string + cause?: unknown +}> {} + +/** + * AuxiliaryData based on Conway CDDL specification. + * + * CDDL (Conway era): + * auxiliary_data = { + * ? 0 => metadata ; transaction_metadata + * ? 1 => [* native_script] ; native_scripts + * ? 2 => [* plutus_v1_script] ; plutus_v1_scripts + * ? 3 => [* plutus_v2_script] ; plutus_v2_scripts + * ? 4 => [* plutus_v3_script] ; plutus_v3_scripts + * } + * + * Uses map format with numeric keys as per Conway specification. + * + * @since 2.0.0 + * @category model + */ +export class AuxiliaryData extends Schema.Class("AuxiliaryData")({ + metadata: Schema.optional(Metadata.Metadata), + nativeScripts: Schema.optional(Schema.Array(NativeScripts.Native)), + plutusV1Scripts: Schema.optional(Schema.Array(PlutusV1.PlutusV1)), + plutusV2Scripts: Schema.optional(Schema.Array(PlutusV2.PlutusV2)), + plutusV3Scripts: Schema.optional(Schema.Array(PlutusV3.PlutusV3)) +}) {} + +/** + * Tagged CDDL schema for AuxiliaryData (#6.259 wrapping the struct). + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = CBOR.tag( + 259, + Schema.Struct({ + 0: Schema.optional(Metadata.CDDLSchema), + 1: Schema.optional(Schema.Array(NativeScripts.CDDLSchema)), + 2: Schema.optional(Schema.Array(PlutusV1.CDDLSchema)), + 3: Schema.optional(Schema.Array(PlutusV2.CDDLSchema)), + 4: Schema.optional(Schema.Array(PlutusV3.CDDLSchema)) + }) +) + +/** + * Transform between tagged CDDL (tag 259) and AuxiliaryData class. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(AuxiliaryData), { + strict: true, + encode: (toA) => + Eff.gen(function* () { + const struct: Record = {} + if (toA.metadata !== undefined) struct[0] = yield* ParseResult.encode(Metadata.FromCDDL)(toA.metadata) + if (toA.nativeScripts !== undefined) + struct[1] = yield* Eff.all(toA.nativeScripts.map((s) => ParseResult.encode(NativeScripts.FromCDDL)(s))) + if (toA.plutusV1Scripts !== undefined) + struct[2] = yield* Eff.all(toA.plutusV1Scripts.map((s) => ParseResult.encode(PlutusV1.FromCDDL)(s))) + if (toA.plutusV2Scripts !== undefined) + struct[3] = yield* Eff.all(toA.plutusV2Scripts.map((s) => ParseResult.encode(PlutusV2.FromCDDL)(s))) + if (toA.plutusV3Scripts !== undefined) + struct[4] = yield* Eff.all(toA.plutusV3Scripts.map((s) => ParseResult.encode(PlutusV3.FromCDDL)(s))) + return { value: struct, tag: 259 as const, _tag: "Tag" as const } + }), + decode: (tagged) => + Eff.gen(function* () { + const struct = tagged.value + const metadata = struct[0] ? yield* ParseResult.decode(Metadata.FromCDDL)(struct[0]) : undefined + const nativeScripts = struct[1] + ? yield* Eff.all(struct[1].map((s) => ParseResult.decode(NativeScripts.FromCDDL)(s))) + : undefined + const plutusV1Scripts = struct[2] + ? yield* Eff.all(struct[2].map((s) => ParseResult.decode(PlutusV1.FromCDDL)(s))) + : undefined + const plutusV2Scripts = struct[3] + ? yield* Eff.all(struct[3].map((s) => ParseResult.decode(PlutusV2.FromCDDL)(s))) + : undefined + const plutusV3Scripts = struct[4] + ? yield* Eff.all(struct[4].map((s) => ParseResult.decode(PlutusV3.FromCDDL)(s))) + : undefined + return new AuxiliaryData({ metadata, nativeScripts, plutusV1Scripts, plutusV2Scripts, plutusV3Scripts }) + }) +}).annotations({ + identifier: "AuxiliaryData.FromCDDL", + title: "AuxiliaryData from tagged CDDL", + description: "Transforms CBOR tag 259 CDDL structure to AuxiliaryData" +}) + +/** + * CBOR bytes transformation schema for AuxiliaryData. + * Transforms between CBOR bytes and AuxiliaryData using CDDL format. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose(CBOR.FromBytes(options), FromCDDL).annotations({ + identifier: "AuxiliaryData.FromCBORBytes", + title: "AuxiliaryData from CBOR bytes", + description: "Decode AuxiliaryData from CBOR-encoded bytes (tag 259)" + }) + +/** + * CBOR hex transformation schema for AuxiliaryData. + * Transforms between CBOR hex string and AuxiliaryData using CDDL format. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose(Bytes.FromHex, FromCBORBytes(options)).annotations({ + identifier: "AuxiliaryData.FromCBORHex", + title: "AuxiliaryData from CBOR hex", + description: "Decode AuxiliaryData from CBOR-encoded hex (tag 259)" + }) + +/** + * Smart constructor for AuxiliaryData with validation. + * + * @since 2.0.0 + * @category constructors + */ +export const make = AuxiliaryData.make + +/** + * Create an empty AuxiliaryData instance. + * + * @since 2.0.0 + * @category constructors + */ +export const empty = (): AuxiliaryData => + new AuxiliaryData({ + metadata: undefined, + nativeScripts: undefined, + plutusV1Scripts: undefined, + plutusV2Scripts: undefined, + plutusV3Scripts: undefined + }) + +/** + * Check if two AuxiliaryData instances are equal (deep comparison). + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: AuxiliaryData, b: AuxiliaryData): boolean => { + if (a.metadata && b.metadata) { + if (!Metadata.equals(a.metadata, b.metadata)) return false + } else if (a.metadata || b.metadata) return false + + const cmpArray = (x?: ReadonlyArray, y?: ReadonlyArray) => + x && y ? x.length === y.length && x.every((v, i) => v === y[i]) : x === y + + if (!cmpArray(a.nativeScripts, b.nativeScripts)) return false + if (!cmpArray(a.plutusV1Scripts, b.plutusV1Scripts)) return false + if (!cmpArray(a.plutusV2Scripts, b.plutusV2Scripts)) return false + if (!cmpArray(a.plutusV3Scripts, b.plutusV3Scripts)) return false + return true +} + +/** + * FastCheck arbitrary for generating random AuxiliaryData instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary: FastCheck.Arbitrary = FastCheck.record({ + metadata: FastCheck.option(Metadata.arbitrary, { nil: undefined }), + nativeScripts: FastCheck.option( + FastCheck.array( + // basic native script arbitrary using keyHash sig scripts only for now + FastCheck.record({ + type: FastCheck.constant("sig" as const), + keyHash: FastCheck.hexaString({ minLength: 56, maxLength: 56 }) + }), + { maxLength: 3 } + ), + { nil: undefined } + ), + plutusV1Scripts: FastCheck.option(FastCheck.array(PlutusV1.arbitrary, { maxLength: 3 }), { nil: undefined }), + plutusV2Scripts: FastCheck.option(FastCheck.array(PlutusV2.arbitrary, { maxLength: 3 }), { nil: undefined }), + plutusV3Scripts: FastCheck.option(FastCheck.array(PlutusV3.arbitrary, { maxLength: 3 }), { nil: undefined }) +}).map((r) => new AuxiliaryData(r)) + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Decode AuxiliaryData from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS +): AuxiliaryData => Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Decode AuxiliaryData from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS): AuxiliaryData => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Encode AuxiliaryData to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (value: AuxiliaryData, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS): Uint8Array => + Eff.runSync(Effect.toCBORBytes(value, options)) + +/** + * Encode AuxiliaryData to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (value: AuxiliaryData, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS): string => + Eff.runSync(Effect.toCBORHex(value, options)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Decode AuxiliaryData from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataError({ + message: "Failed to decode AuxiliaryData from CBOR bytes", + cause + }) + ) + ) as Eff.Effect + + /** + * Decode AuxiliaryData from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataError({ + message: "Failed to decode AuxiliaryData from CBOR hex", + cause + }) + ) + ) + + /** + * Encode AuxiliaryData to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = ( + value: AuxiliaryData, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(value).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataError({ + message: "Failed to encode AuxiliaryData to CBOR bytes", + cause + }) + ) + ) + + /** + * Encode AuxiliaryData to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = ( + value: AuxiliaryData, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORHex(options))(value).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataError({ + message: "Failed to encode AuxiliaryData to CBOR hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/AuxiliaryDataHash.ts b/packages/evolution/src/AuxiliaryDataHash.ts index cbadd341..4e687adb 100644 --- a/packages/evolution/src/AuxiliaryDataHash.ts +++ b/packages/evolution/src/AuxiliaryDataHash.ts @@ -7,10 +7,9 @@ * @since 2.0.0 */ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes32 from "./Bytes32.js" -import { createEncoders } from "./Codec.js" /** * Error class for AuxiliaryDataHash related operations. @@ -30,7 +29,7 @@ export class AuxiliaryDataHashError extends Data.TaggedError("AuxiliaryDataHashE * @since 2.0.0 * @category schemas */ -export const AuxiliaryDataHash = pipe(Bytes32.HexSchema, Schema.brand("AuxiliaryDataHash")).annotations({ +export const AuxiliaryDataHash = Bytes32.HexSchema.pipe(Schema.brand("AuxiliaryDataHash")).annotations({ identifier: "AuxiliaryDataHash" }) @@ -50,6 +49,14 @@ export const HexSchema = Schema.compose( identifier: "AuxiliaryDataHash.Hex" }) +/** + * Smart constructor for AuxiliaryDataHash that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = AuxiliaryDataHash.make + /** * Check if two AuxiliaryDataHash instances are equal. * @@ -59,26 +66,136 @@ export const HexSchema = Schema.compose( export const equals = (a: AuxiliaryDataHash, b: AuxiliaryDataHash): boolean => a === b /** - * Generate a random AuxiliaryDataHash. + * Check if the given value is a valid AuxiliaryDataHash + * + * @since 2.0.0 + * @category predicates + */ +export const isAuxiliaryDataHash = Schema.is(AuxiliaryDataHash) + +/** + * FastCheck arbitrary for generating random AuxiliaryDataHash instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes32.Bytes32_BYTES_LENGTH, - maxLength: Bytes32.Bytes32_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes32.HEX_LENGTH, + maxLength: Bytes32.HEX_LENGTH +}).map((hex) => hex as AuxiliaryDataHash) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for AuxiliaryDataHash encoding and decoding operations. + * Parse AuxiliaryDataHash from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: BytesSchema, - hex: HexSchema - }, - AuxiliaryDataHashError -) +export const fromBytes = (bytes: Uint8Array): AuxiliaryDataHash => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse AuxiliaryDataHash from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): AuxiliaryDataHash => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode AuxiliaryDataHash to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (auxDataHash: AuxiliaryDataHash): Uint8Array => Eff.runSync(Effect.toBytes(auxDataHash)) + +/** + * Encode AuxiliaryDataHash to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (auxDataHash: AuxiliaryDataHash): string => Eff.runSync(Effect.toHex(auxDataHash)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse AuxiliaryDataHash from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(BytesSchema)(bytes).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataHashError({ + message: "Failed to parse AuxiliaryDataHash from bytes", + cause + }) + ) + ) + + /** + * Parse AuxiliaryDataHash from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(HexSchema)(hex).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataHashError({ + message: "Failed to parse AuxiliaryDataHash from hex", + cause + }) + ) + ) + + /** + * Encode AuxiliaryDataHash to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (auxDataHash: AuxiliaryDataHash): Eff.Effect => + Schema.encode(BytesSchema)(auxDataHash).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataHashError({ + message: "Failed to encode AuxiliaryDataHash to bytes", + cause + }) + ) + ) + + /** + * Encode AuxiliaryDataHash to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (auxDataHash: AuxiliaryDataHash): Eff.Effect => + Schema.encode(HexSchema)(auxDataHash).pipe( + Eff.mapError( + (cause) => + new AuxiliaryDataHashError({ + message: "Failed to encode AuxiliaryDataHash to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/BaseAddress.ts b/packages/evolution/src/BaseAddress.ts index af8576bc..26af8efc 100644 --- a/packages/evolution/src/BaseAddress.ts +++ b/packages/evolution/src/BaseAddress.ts @@ -1,8 +1,7 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as Bytes57 from "./Bytes57.js" -import * as _Codec from "./Codec.js" import * as Credential from "./Credential.js" import * as KeyHash from "./KeyHash.js" import * as NetworkId from "./NetworkId.js" @@ -23,21 +22,12 @@ export class BaseAddress extends Schema.TaggedClass("BaseAddress")( networkId: NetworkId.NetworkId, paymentCredential: Credential.Credential, stakeCredential: Credential.Credential -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: "BaseAddress", - networkId: this.networkId, - paymentCredential: this.paymentCredential, - stakeCredential: this.stakeCredential - } - } -} +}) {} export const FromBytes = Schema.transformOrFail(Bytes57.BytesSchema, BaseAddress, { strict: true, encode: (_, __, ___, toA) => - Effect.gen(function* () { + Eff.gen(function* () { const paymentBit = toA.paymentCredential._tag === "KeyHash" ? 0 : 1 const stakeBit = toA.stakeCredential._tag === "KeyHash" ? 0 : 1 const header = (0b00 << 6) | (stakeBit << 5) | (paymentBit << 4) | (toA.networkId & 0b00001111) @@ -53,7 +43,7 @@ export const FromBytes = Schema.transformOrFail(Bytes57.BytesSchema, BaseAddress return yield* ParseResult.succeed(result) }), decode: (fromI, options, ast, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const header = fromA[0] // Extract network ID from the lower 4 bits const networkId = header & 0b00001111 @@ -68,7 +58,7 @@ export const FromBytes = Schema.transformOrFail(Bytes57.BytesSchema, BaseAddress } : { _tag: "ScriptHash", - hash: yield* ParseResult.decode(ScriptHash.BytesSchema)(fromA.slice(1, 29)) + hash: yield* ParseResult.decode(ScriptHash.FromBytes)(fromA.slice(1, 29)) } const isStakeKey = (addressType & 0b0010) === 0 const stakeCredential: Credential.Credential = isStakeKey @@ -78,7 +68,7 @@ export const FromBytes = Schema.transformOrFail(Bytes57.BytesSchema, BaseAddress } : { _tag: "ScriptHash", - hash: yield* ParseResult.decode(ScriptHash.BytesSchema)(fromA.slice(29, 57)) + hash: yield* ParseResult.decode(ScriptHash.FromBytes)(fromA.slice(29, 57)) } return yield* ParseResult.decode(BaseAddress)({ _tag: "BaseAddress", @@ -110,12 +100,20 @@ export const equals = (a: BaseAddress, b: BaseAddress): boolean => { } /** - * Generate a random BaseAddress. + * Smart constructor for BaseAddress. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Schema.decodeSync(BaseAddress) + +/** + * FastCheck arbitrary for BaseAddress instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.tuple(NetworkId.generator, Credential.generator, Credential.generator).map( +export const arbitrary = FastCheck.tuple(NetworkId.arbitrary, Credential.arbitrary, Credential.arbitrary).map( ([networkId, paymentCredential, stakeCredential]) => new BaseAddress({ networkId, @@ -124,10 +122,103 @@ export const generator = FastCheck.tuple(NetworkId.generator, Credential.generat }) ) -export const Codec = _Codec.createEncoders( - { - hex: FromHex, - bytes: FromBytes - }, - BaseAddressError -) +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse a BaseAddress from bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): BaseAddress => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse a BaseAddress from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): BaseAddress => Eff.runSync(Effect.fromHex(hex)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a BaseAddress to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (address: BaseAddress): Uint8Array => Eff.runSync(Effect.toBytes(address)) + +/** + * Convert a BaseAddress to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (address: BaseAddress): string => Eff.runSync(Effect.toHex(address)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse a BaseAddress from bytes. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array) => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (error) => new BaseAddressError({ message: "Failed to decode BaseAddress from bytes", cause: error }) + ) + + /** + * Parse a BaseAddress from hex string. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string) => + Eff.mapError( + Schema.decode(FromHex)(hex), + (error) => new BaseAddressError({ message: "Failed to decode BaseAddress from hex", cause: error }) + ) + + /** + * Convert a BaseAddress to bytes. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (address: BaseAddress) => + Eff.mapError( + Schema.encode(FromBytes)(address), + (error) => new BaseAddressError({ message: "Failed to encode BaseAddress to bytes", cause: error }) + ) + + /** + * Convert a BaseAddress to hex string. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (address: BaseAddress) => + Eff.mapError( + Schema.encode(FromHex)(address), + (error) => new BaseAddressError({ message: "Failed to encode BaseAddress to hex", cause: error }) + ) +} diff --git a/packages/evolution/src/Bech32.ts b/packages/evolution/src/Bech32.ts index 0d8553e4..4233e982 100644 --- a/packages/evolution/src/Bech32.ts +++ b/packages/evolution/src/Bech32.ts @@ -1,6 +1,8 @@ import { bech32 } from "@scure/base" import { Data, Effect, ParseResult, Schema } from "effect" +import * as Bytes from "./Bytes.js" + /** * @since 2.0.0 * @category model @@ -21,8 +23,13 @@ export const FromBytes = (prefix: string = "addr") => try: () => bech32.decodeToBytes(toA).bytes, catch: () => new ParseResult.Type(ast, toA, ` ${toA} is not a valid Bech32 address`) }), - decode: (fromA, options, ast, fromI) => { + decode: (_, __, ___, fromI) => { const words = bech32.toWords(fromI) return ParseResult.succeed(bech32.encode(prefix, words, false)) } }) + +export const FromHex = (prefix: string = "addr") => + Schema.compose(Bytes.FromHex, FromBytes(prefix)).annotations({ + identifier: "Bech32.FromHex" + }) diff --git a/packages/evolution/src/Bip32PrivateKey.ts b/packages/evolution/src/Bip32PrivateKey.ts new file mode 100644 index 00000000..b71e3c99 --- /dev/null +++ b/packages/evolution/src/Bip32PrivateKey.ts @@ -0,0 +1,740 @@ +import { pbkdf2 } from "@noble/hashes/pbkdf2" +import { sha512 } from "@noble/hashes/sha2" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" +import sodium from "libsodium-wrappers-sumo" + +import * as Bip32PublicKey from "./Bip32PublicKey.js" +import * as Bytes96 from "./Bytes96.js" +import * as PrivateKey from "./PrivateKey.js" + +// Initialize libsodium +await sodium.ready + +/** + * Error class for Bip32PrivateKey related operations. + * + * @since 2.0.0 + * @category errors + */ +export class Bip32PrivateKeyError extends Data.TaggedError("Bip32PrivateKeyError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Schema for Bip32PrivateKey representing a BIP32-Ed25519 extended private key. + * Always 96 bytes: 32-byte scalar + 32-byte IV + 32-byte chaincode. + * Follows BIP32-Ed25519 hierarchical deterministic key derivation. + * Uses V2 derivation scheme for full CML (Cardano Multiplatform Library) compatibility. + * + * @since 2.0.0 + * @category schemas + */ +export const Bip32PrivateKey = Bytes96.HexSchema.pipe(Schema.brand("Bip32PrivateKey")).annotations({ + identifier: "Bip32PrivateKey" +}) + +export type Bip32PrivateKey = typeof Bip32PrivateKey.Type + +export const FromBytes = Schema.compose( + Bytes96.FromBytes, // Uint8Array -> hex string + Bip32PrivateKey // hex string -> Bip32PrivateKey +).annotations({ + identifier: "Bip32PrivateKey.Bytes" +}) + +export const FromHex = Schema.compose( + Bytes96.HexSchema, // string -> hex string + Bip32PrivateKey // hex string -> Bip32PrivateKey +).annotations({ + identifier: "Bip32PrivateKey.Hex" +}) + +// Constants for BIP32-Ed25519 +const SCALAR_INDEX = 0 +const SCALAR_SIZE = 32 +const CHAIN_CODE_INDEX = 64 +const CHAIN_CODE_SIZE = 32 +const PBKDF2_ITERATIONS = 4096 +const PBKDF2_KEY_SIZE = 96 + +/** + * Clamp the scalar by: + * 1. Clearing the 3 lower bits + * 2. Clearing the three highest bits + * 3. Setting the second-highest bit + * + * This follows Ed25519-BIP32 specification requirements. + */ +const clampScalar = (scalar: Uint8Array): Uint8Array => { + const clamped = new Uint8Array(scalar) + clamped[0] &= 0b1111_1000 + clamped[31] &= 0b0001_1111 + clamped[31] |= 0b0100_0000 + return clamped +} + +/** + * Extract the scalar part (first 32 bytes) from the extended key. + */ +const extractScalar = (extendedKey: Uint8Array): Uint8Array => extendedKey.slice(SCALAR_INDEX, SCALAR_SIZE) + +/** + * Extract the chaincode part (bytes 64-95) from the extended key. + */ +const extractChainCode = (extendedKey: Uint8Array): Uint8Array => + extendedKey.slice(CHAIN_CODE_INDEX, CHAIN_CODE_INDEX + CHAIN_CODE_SIZE) + +/** + * Smart constructor for Bip32PrivateKey that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Bip32PrivateKey.make + +/** + * Check if two Bip32PrivateKey instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Bip32PrivateKey, b: Bip32PrivateKey): boolean => a === b + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse a Bip32PrivateKey from raw bytes (96 bytes). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): Bip32PrivateKey => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse a Bip32PrivateKey from a hex string (192 hex characters). + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): Bip32PrivateKey => Eff.runSync(Effect.fromHex(hex)) + +/** + * Create a Bip32PrivateKey from BIP39 entropy with PBKDF2 key stretching. + * This is the proper way to generate a master key from a BIP39 seed. + * + * @since 2.0.0 + * @category bip39 + */ +export const fromBip39Entropy = (entropy: Uint8Array, password: string = ""): Bip32PrivateKey => + Eff.runSync(Effect.fromBip39Entropy(entropy, password)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a Bip32PrivateKey to raw bytes (96 bytes). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (bip32PrivateKey: Bip32PrivateKey): Uint8Array => Eff.runSync(Effect.toBytes(bip32PrivateKey)) + +/** + * Convert a Bip32PrivateKey to a hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (bip32PrivateKey: Bip32PrivateKey): string => bip32PrivateKey // Already a hex string + +// ============================================================================ +// Key Derivation +// ============================================================================ + +/** + * Derive a child private key using a single derivation index. + * Supports both hard derivation (>= 0x80000000) and soft derivation (< 0x80000000). + * + * @since 2.0.0 + * @category bip32 + */ +export const deriveChild = (bip32PrivateKey: Bip32PrivateKey, index: number): Bip32PrivateKey => + Eff.runSync(Effect.deriveChild(bip32PrivateKey, index)) + +/** + * Derive a child private key using multiple derivation indices. + * Each index can be either hard or soft derivation. + * + * @since 2.0.0 + * @category bip32 + */ +export const derive = (bip32PrivateKey: Bip32PrivateKey, indices: Array): Bip32PrivateKey => + Eff.runSync(Effect.derive(bip32PrivateKey, indices)) + +/** + * Derive a child private key using a BIP32 path string. + * Supports paths like "m/1852'/1815'/0'/0/0" or "1852'/1815'/0'/0/0". + * + * @since 2.0.0 + * @category bip32 + */ +export const derivePath = (bip32PrivateKey: Bip32PrivateKey, path: string): Bip32PrivateKey => + Eff.runSync(Effect.derivePath(bip32PrivateKey, path)) + +// ============================================================================ +// Key Conversion +// ============================================================================ + +/** + * Convert a Bip32PrivateKey to a standard PrivateKey for signing operations. + * Extracts the first 64 bytes (scalar + IV) for Ed25519 signing. + * + * @since 2.0.0 + * @category conversion + */ +export const toPrivateKey = (bip32PrivateKey: Bip32PrivateKey): PrivateKey.PrivateKey => + Eff.runSync(Effect.toPrivateKey(bip32PrivateKey)) + +// ============================================================================ +// CML Compatibility Functions +// ============================================================================ + +/** + * Serialize Bip32PrivateKey to CML-compatible 128-byte format. + * Format: [private_key(32)] + [IV(32)] + [public_key(32)] + [chain_code(32)] + * This matches the format expected by CML.Bip32PrivateKey.from_128_xprv() + * + * @since 2.0.0 + * @category cml-compatibility + */ +export const to128XPRV = (bip32PrivateKey: Bip32PrivateKey): Uint8Array => + Eff.runSync(Effect.to_128_xprv(bip32PrivateKey)) + +/** + * Create Bip32PrivateKey from CML-compatible 128-byte format. + * Format: [private_key(32)] + [IV(32)] + [public_key(32)] + [chain_code(32)] + * This matches the format returned by CML.Bip32PrivateKey.to_128_xprv() + * + * @since 2.0.0 + * @category cml-compatibility + */ +export const from128XPRV = (bytes: Uint8Array): Bip32PrivateKey => Eff.runSync(Effect.from_128_xprv(bytes)) + +// ============================================================================ +// Public Key Derivation +// ============================================================================ + +/** + * Derive the public key from this BIP32 private key. + * Uses the scalar part for Ed25519 point multiplication. + * + * @since 2.0.0 + * @category cryptography + */ +export const toPublicKey = (bip32PrivateKey: Bip32PrivateKey): Bip32PublicKey.Bip32PublicKey => + Eff.runSync(Effect.toPublicKey(bip32PrivateKey)) + +// ============================================================================ +// FastCheck Arbitrary +// ============================================================================ + +/** + * FastCheck arbitrary for generating random Bip32PrivateKey instances for testing. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.uint8Array({ + minLength: 96, + maxLength: 96 +}).map((bytes) => Eff.runSync(Effect.fromBytes(bytes))) + +// ============================================================================ +// Cardano-specific utilities +// ============================================================================ + +/** + * Cardano BIP44 derivation path utilities for BIP32 keys. + * + * @since 2.0.0 + * @category cardano + */ +export const CardanoPath = { + /** + * Create a Cardano BIP44 derivation path as indices array. + * Standard path: [1852', 1815', account', role, index] + */ + indices: (account: number = 0, role: 0 | 2 = 0, index: number = 0): Array => [ + 0x80000000 + 1852, // Purpose: 1852' (hardened) + 0x80000000 + 1815, // Coin type: ADA (hardened) + 0x80000000 + account, // Account (hardened) + role, // Role: 0=payment, 2=stake (not hardened) + index // Index (not hardened) + ], + + /** + * Payment key indices (role = 0) + */ + paymentIndices: (account: number = 0, index: number = 0) => CardanoPath.indices(account, 0, index), + + /** + * Stake key indices (role = 2) + */ + stakeIndices: (account: number = 0, index: number = 0) => CardanoPath.indices(account, 2, index) +} + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse a Bip32PrivateKey from raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.gen(function* () { + if (bytes.length !== 96) { + return yield* Eff.fail( + new Bip32PrivateKeyError({ + message: `Expected 96 bytes, got ${bytes.length} bytes` + }) + ) + } + return yield* Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to parse Bip32PrivateKey from bytes", + cause + }) + ) + }) + + /** + * Parse a Bip32PrivateKey from a hex string using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Eff.mapError( + Schema.decode(Bip32PrivateKey)(hex), + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to parse Bip32PrivateKey from hex", + cause + }) + ) + + /** + * Create a Bip32PrivateKey from BIP39 entropy using Effect error handling. + * + * @since 2.0.0 + * @category bip39 + */ + export const fromBip39Entropy = ( + entropy: Uint8Array, + password: string = "" + ): Eff.Effect => + Eff.gen(function* () { + const keyMaterial = yield* Eff.try(() => + pbkdf2(sha512, password, entropy, { c: PBKDF2_ITERATIONS, dkLen: PBKDF2_KEY_SIZE }) + ) + + // Clamp the scalar part (first 32 bytes) + const clamped = new Uint8Array(keyMaterial) + clamped.set(clampScalar(keyMaterial.slice(0, 32)), 0) + + return yield* Schema.decode(FromBytes)(clamped) + }).pipe( + Eff.mapError( + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to generate Bip32PrivateKey from BIP39 entropy", + cause + }) + ) + ) + + /** + * Convert a Bip32PrivateKey to raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (bip32PrivateKey: Bip32PrivateKey): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(bip32PrivateKey), + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to encode Bip32PrivateKey to bytes", + cause + }) + ) + + /** + * Derive a child private key using a single index with Effect error handling. + * + * @since 2.0.0 + * @category bip32 + */ + export const deriveChild = ( + bip32PrivateKey: Bip32PrivateKey, + index: number + ): Eff.Effect => + Eff.gen(function* () { + const keyBytes = yield* Schema.encode(FromBytes)(bip32PrivateKey) + + // For soft derivation, we need the computed public key bytes, not the Bip32PublicKey + const computedPublicKeyBytes = yield* Eff.try(() => { + const scalar = extractScalar(keyBytes) + return sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + }) + + const derivedBytes = yield* Eff.try(() => { + const scalar = keyBytes.slice(0, 32) // First 32 bytes: scalar + const iv = keyBytes.slice(32, 64) // Second 32 bytes: IV + const chainCode = extractChainCode(keyBytes) + + // Determine if this is hardened or soft derivation + const isHardened = index >= 0x80000000 + const actualIndex = index // Use the actual index, don't force hardened + + // Serialize index in little-endian (V2 scheme) - CML compatible + const indexBytes = new Uint8Array(4) + indexBytes[0] = actualIndex & 0xff + indexBytes[1] = (actualIndex >>> 8) & 0xff + indexBytes[2] = (actualIndex >>> 16) & 0xff + indexBytes[3] = (actualIndex >>> 24) & 0xff + + // Create HMAC input for Z - use appropriate tag and key material + let zInput: Uint8Array + if (isHardened) { + // Hardened derivation: tag(0x00) + scalar(32) + iv(32) + index(4 bytes) + const zTag = new Uint8Array([0x00]) // TAG_DERIVE_Z_HARDENED + zInput = new Uint8Array(1 + 32 + 32 + 4) + zInput.set(zTag, 0) + zInput.set(scalar, 1) + zInput.set(iv, 33) + zInput.set(indexBytes, 65) + } else { + // Soft derivation: tag(0x02) + public_key(32 bytes) + index(4 bytes) + const zTag = new Uint8Array([0x02]) // TAG_DERIVE_Z_SOFT - try 0x02 + zInput = new Uint8Array(1 + 32 + 4) + zInput.set(zTag, 0) + zInput.set(computedPublicKeyBytes, 1) + zInput.set(indexBytes, 33) + } + + // HMAC-SHA512 with chain code as key + const hmacZ = sodium.crypto_auth_hmacsha512(zInput, chainCode) + + const z = new Uint8Array(hmacZ) + const zl = z.slice(0, 32) + const zr = z.slice(32, 64) + + // multiply8_v2: multiply by 8 using DERIVATION_V2 scheme (CML compatible) + // This implements add_28_mul8_v2(kl, zl) where kl is the scalar (left half) + const kl = scalar // Use the scalar, not the first 32 bytes of 64-byte "private key" + const scaledLeft = new Uint8Array(32) + let carry = 0 + // First 28 bytes: kl[i] + (zl[i] << 3) + carry + for (let i = 0; i < 28; i++) { + const r = kl[i] + (zl[i] << 3) + carry + scaledLeft[i] = r & 0xff + carry = r >> 8 + } + // Last 4 bytes: kl[i] + carry (no shift) + for (let i = 28; i < 32; i++) { + const r = kl[i] + carry + scaledLeft[i] = r & 0xff + carry = r >> 8 + } + + // scalar_add_no_overflow: The left half is already computed in scaledLeft + const newKeyMaterial = new Uint8Array(64) + // Use the computed scaledLeft directly for left half (new scalar) + newKeyMaterial.set(scaledLeft, 0) + + // Add right half (zr + iv) - the IV becomes the new right half + let carryBit = 0 + for (let i = 0; i < 32; i++) { + const sum = iv[i] + zr[i] + carryBit + newKeyMaterial[i + 32] = sum & 0xff + carryBit = sum > 255 ? 1 : 0 + } + + // Derive new chain code: use appropriate tag and key material + let ccInput: Uint8Array + if (isHardened) { + // Hardened derivation: tag(0x01) + scalar(32) + iv(32) + index(4 bytes) + const ccTag = new Uint8Array([0x01]) // TAG_DERIVE_CC_HARDENED + ccInput = new Uint8Array(1 + 32 + 32 + 4) + ccInput.set(ccTag, 0) + ccInput.set(scalar, 1) + ccInput.set(iv, 33) + ccInput.set(indexBytes, 65) + } else { + // Soft derivation: tag(0x03) + public_key(32 bytes) + index(4 bytes) + const ccTag = new Uint8Array([0x03]) // TAG_DERIVE_CC_SOFT - corrected to 0x03 + ccInput = new Uint8Array(1 + 32 + 4) + ccInput.set(ccTag, 0) + ccInput.set(computedPublicKeyBytes, 1) + ccInput.set(indexBytes, 33) + } + + const hmacCC = sodium.crypto_auth_hmacsha512(ccInput, chainCode) + const newChainCode = new Uint8Array(hmacCC).slice(32, 64) // Take right 32 bytes + + // Construct the new key: newKeyMaterial(64 bytes) + newChainCode(32 bytes) = 96 bytes + return new Uint8Array([...newKeyMaterial, ...newChainCode]) + }) + + return yield* Schema.decode(FromBytes)(derivedBytes) + }).pipe( + Eff.mapError( + (cause) => + new Bip32PrivateKeyError({ + message: `Failed to derive child key with index ${index}`, + cause + }) + ) + ) + + /** + * Derive a child private key using multiple indices with Effect error handling. + * + * @since 2.0.0 + * @category bip32 + */ + export const derive = ( + bip32PrivateKey: Bip32PrivateKey, + indices: Array + ): Eff.Effect => + Eff.gen(function* () { + let currentKey = bip32PrivateKey + + for (const index of indices) { + currentKey = yield* deriveChild(currentKey, index) + } + + return currentKey + }) + + /** + * Parse a BIP32 derivation path string into indices array. + * + * @since 2.0.0 + * @category bip32 + */ + const parsePath = (path: string): Eff.Effect, Bip32PrivateKeyError> => + Eff.try(() => { + const cleanPath = path.startsWith("m/") ? path.slice(2) : path + const segments = cleanPath.split("/") + + return segments.map((segment) => { + const isHardened = segment.endsWith("'") || segment.endsWith("h") + const indexStr = isHardened ? segment.slice(0, -1) : segment + const index = parseInt(indexStr, 10) + + if (isNaN(index)) { + throw new Error(`Invalid path segment: ${segment}`) + } + + return isHardened ? 0x80000000 + index : index + }) + }).pipe( + Eff.mapError( + (cause) => + new Bip32PrivateKeyError({ + message: `Failed to parse derivation path: ${path}`, + cause + }) + ) + ) + + /** + * Derive a child private key using a path string with Effect error handling. + * + * @since 2.0.0 + * @category bip32 + */ + export const derivePath = ( + bip32PrivateKey: Bip32PrivateKey, + path: string + ): Eff.Effect => + Eff.gen(function* () { + const indices = yield* parsePath(path) + return yield* derive(bip32PrivateKey, indices) + }) + + /** + * Convert a Bip32PrivateKey to a standard PrivateKey using Effect error handling. + * + * @since 2.0.0 + * @category conversion + */ + export const toPrivateKey = ( + bip32PrivateKey: Bip32PrivateKey + ): Eff.Effect => + Eff.gen(function* () { + const keyBytes = yield* toBytes(bip32PrivateKey) + const privateKeyBytes = keyBytes.slice(0, 64) // scalar + IV + + return yield* Eff.mapError( + Schema.decode(PrivateKey.FromBytes)(privateKeyBytes), + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to convert Bip32PrivateKey to PrivateKey", + cause + }) + ) + }) + + /** + * Derive the public key from this BIP32 private key using Effect error handling. + * + * @since 2.0.0 + * @category cryptography + */ + export const toPublicKey = ( + bip32PrivateKey: Bip32PrivateKey + ): Eff.Effect => + Eff.gen(function* () { + const keyBytes = yield* Schema.encode(FromBytes)(bip32PrivateKey) + + const publicKeyBytes = yield* Eff.try(() => { + const scalar = extractScalar(keyBytes) + return sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + }) + + const chainCode = extractChainCode(keyBytes) + + return yield* Eff.mapError( + Bip32PublicKey.Effect.fromBytes(publicKeyBytes, chainCode), + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to create Bip32PublicKey", + cause + }) + ) + }).pipe( + Eff.mapError( + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to derive public key from Bip32PrivateKey", + cause + }) + ) + ) + + // ============================================================================ + // CML Compatibility Methods + // ============================================================================ + + /** + * Serialize Bip32PrivateKey to CML-compatible 128-byte format using Effect error handling. + * Format: [private_key(32)] + [IV(32)] + [public_key(32)] + [chain_code(32)] + * This matches the format expected by CML.Bip32PrivateKey.from_128_xprv() + * + * @since 2.0.0 + * @category cml-compatibility + */ + export const to_128_xprv = (bip32PrivateKey: Bip32PrivateKey): Eff.Effect => + Eff.gen(function* () { + const keyBytes = yield* Eff.mapError( + Schema.encode(FromBytes)(bip32PrivateKey), + (cause) => new Bip32PrivateKeyError({ message: "Failed to encode key bytes", cause }) + ) + const publicKey = yield* toPublicKey(bip32PrivateKey) + const publicKeyBytes = yield* Eff.mapError( + Bip32PublicKey.Effect.toBytes(publicKey), + (cause) => new Bip32PrivateKeyError({ message: "Failed to get public key bytes", cause }) + ) + + // Extract components from our 96-byte format: [scalar(32)] + [IV(32)] + [chaincode(32)] + const scalar = keyBytes.slice(0, 32) + const iv = keyBytes.slice(32, 64) + const chaincode = keyBytes.slice(64, 96) + + // Extract just the public key part (first 32 bytes) from the public key bytes + const publicKeyOnly = publicKeyBytes.slice(0, 32) + + // Construct CML's 128-byte format: [scalar(32)] + [IV(32)] + [public_key(32)] + [chaincode(32)] + const cmlFormat = new Uint8Array(128) + cmlFormat.set(scalar, 0) // Bytes 0-31: private key + cmlFormat.set(iv, 32) // Bytes 32-63: IV/extension + cmlFormat.set(publicKeyOnly, 64) // Bytes 64-95: public key + cmlFormat.set(chaincode, 96) // Bytes 96-127: chain code + + return cmlFormat + }) + + /** + * Create Bip32PrivateKey from CML-compatible 128-byte format using Effect error handling. + * Format: [private_key(32)] + [IV(32)] + [public_key(32)] + [chain_code(32)] + * This matches the format returned by CML.Bip32PrivateKey.to_128_xprv() + * + * @since 2.0.0 + * @category cml-compatibility + */ + export const from_128_xprv = (bytes: Uint8Array): Eff.Effect => + Eff.gen(function* () { + if (bytes.length !== 128) { + return yield* Eff.fail( + new Bip32PrivateKeyError({ + message: `Expected exactly 128 bytes for CML format, got ${bytes.length}` + }) + ) + } + + // Extract components from CML's 128-byte format + const scalar = bytes.slice(0, 32) // Bytes 0-31: private key + const iv = bytes.slice(32, 64) // Bytes 32-63: IV/extension + const chaincode = bytes.slice(96, 128) // Bytes 96-127: chain code + + // Verify the public key matches the private key + const expectedPublicKey = bytes.slice(64, 96) // Bytes 64-95: public key + const derivedPublicKey = yield* Eff.try(() => sodium.crypto_scalarmult_ed25519_base_noclamp(scalar)).pipe( + Eff.mapError((cause) => new Bip32PrivateKeyError({ message: "Failed to derive public key", cause })) + ) + + const publicKeyMatches = derivedPublicKey.every((byte, i) => byte === expectedPublicKey[i]) + if (!publicKeyMatches) { + return yield* Eff.fail( + new Bip32PrivateKeyError({ + message: "Public key does not match private key in 128-byte format" + }) + ) + } + + // Construct our internal 96-byte format: [scalar(32)] + [IV(32)] + [chaincode(32)] + const internalFormat = new Uint8Array(96) + internalFormat.set(scalar, 0) // Bytes 0-31: scalar + internalFormat.set(iv, 32) // Bytes 32-63: IV + internalFormat.set(chaincode, 64) // Bytes 64-95: chaincode + + return yield* Eff.mapError( + Schema.decode(FromBytes)(internalFormat), + (cause) => + new Bip32PrivateKeyError({ + message: "Failed to decode internal format", + cause + }) + ) + }) +} diff --git a/packages/evolution/src/Bip32PublicKey.ts b/packages/evolution/src/Bip32PublicKey.ts new file mode 100644 index 00000000..1e29cae9 --- /dev/null +++ b/packages/evolution/src/Bip32PublicKey.ts @@ -0,0 +1,360 @@ +import { Data, Effect as Eff, FastCheck, Schema } from "effect" +import sodium from "libsodium-wrappers-sumo" + +import * as Bytes64 from "./Bytes64.js" + +// Initialize libsodium +await sodium.ready + +/** + * Error class for Bip32PublicKey related operations. + * + * @since 2.0.0 + * @category errors + */ +export class Bip32PublicKeyError extends Data.TaggedError("Bip32PublicKeyError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Schema for Bip32PublicKey representing a BIP32-Ed25519 extended public key. + * Always 64 bytes: 32-byte public key + 32-byte chaincode. + * Follows BIP32-Ed25519 hierarchical deterministic key derivation. + * Supports soft derivation only (hardened derivation requires private key). + * + * @since 2.0.0 + * @category schemas + */ +export const Bip32PublicKey = Bytes64.HexSchema.pipe(Schema.brand("Bip32PublicKey")).annotations({ + identifier: "Bip32PublicKey" +}) + +export type Bip32PublicKey = typeof Bip32PublicKey.Type + +export const FromBytes = Schema.compose( + Bytes64.FromBytes, // Uint8Array -> hex string + Bip32PublicKey // hex string -> Bip32PublicKey +).annotations({ + identifier: "Bip32PublicKey.Bytes" +}) + +export const FromHex = Schema.compose( + Bytes64.HexSchema, // string -> hex string + Bip32PublicKey // hex string -> Bip32PublicKey +).annotations({ + identifier: "Bip32PublicKey.Hex" +}) + +/** + * Smart constructor for Bip32PublicKey that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Bip32PublicKey.make + +/** + * Check if two Bip32PublicKey instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Bip32PublicKey, b: Bip32PublicKey): boolean => a === b + +// Helper functions for extracting data from the 64-byte format + +/** + * Extract the public key (first 32 bytes) from a Bip32PublicKey. + * + * @since 2.0.0 + * @category accessors + */ +const extractPublicKey = (keyBytes: Uint8Array): Uint8Array => { + if (keyBytes.length !== 64) { + throw new Error(`Expected 64 bytes for Bip32PublicKey, got ${keyBytes.length}`) + } + return keyBytes.slice(0, 32) +} + +/** + * Extract the chain code (last 32 bytes) from a Bip32PublicKey. + * + * @since 2.0.0 + * @category accessors + */ +const extractChainCode = (keyBytes: Uint8Array): Uint8Array => { + if (keyBytes.length !== 64) { + throw new Error(`Expected 64 bytes for Bip32PublicKey, got ${keyBytes.length}`) + } + return keyBytes.slice(32, 64) +} + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Create a BIP32 public key from public key and chain code bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (publicKey: Uint8Array, chainCode: Uint8Array): Bip32PublicKey => { + if (publicKey.length !== 32) { + throw new Error(`Public key must be 32 bytes, got ${publicKey.length}`) + } + if (chainCode.length !== 32) { + throw new Error(`Chain code must be 32 bytes, got ${chainCode.length}`) + } + + const result = new Uint8Array(64) + result.set(publicKey, 0) + result.set(chainCode, 32) + + return Eff.runSync(Schema.decode(FromBytes)(result)) +} + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a Bip32PublicKey to raw bytes (64 bytes). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (bip32PublicKey: Bip32PublicKey): Uint8Array => Eff.runSync(Effect.toBytes(bip32PublicKey)) + +/** + * Convert a Bip32PublicKey to raw public key bytes (32 bytes only). + * + * @since 2.0.0 + * @category encoding + */ +export const toRawBytes = (bip32PublicKey: Bip32PublicKey): Uint8Array => { + const keyBytes = toBytes(bip32PublicKey) + return extractPublicKey(keyBytes) +} + +// ============================================================================ +// Derivation Functions +// ============================================================================ + +/** + * Derive a child public key using the specified index (soft derivation only). + * + * @since 2.0.0 + * @category derivation + */ +export const deriveChild = (bip32PublicKey: Bip32PublicKey, index: number): Bip32PublicKey => + Eff.runSync(Effect.deriveChild(bip32PublicKey, index)) + +// ============================================================================ +// Accessor Functions +// ============================================================================ + +/** + * Get the chain code. + * + * @since 2.0.0 + * @category accessors + */ +export const chainCode = (bip32PublicKey: Bip32PublicKey): Uint8Array => { + const keyBytes = toBytes(bip32PublicKey) + return extractChainCode(keyBytes) +} + +/** + * Get the public key bytes. + * + * @since 2.0.0 + * @category accessors + */ +export const publicKey = (bip32PublicKey: Bip32PublicKey): Uint8Array => { + const keyBytes = toBytes(bip32PublicKey) + return extractPublicKey(keyBytes) +} + +// ============================================================================ +// FastCheck Arbitrary +// ============================================================================ + +/** + * FastCheck arbitrary for generating random Bip32PublicKey instances for testing. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.uint8Array({ + minLength: 64, + maxLength: 64 +}).map((bytes) => Eff.runSync(Effect.fromBytes(bytes.slice(0, 32), bytes.slice(32, 64)))) + +/** + * @since 2.0.0 + * @category Effect + */ +export namespace Effect { + /** + * Create a BIP32 public key from public key and chain code bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = ( + publicKey: Uint8Array, + chainCode: Uint8Array + ): Eff.Effect => + Eff.gen(function* () { + if (publicKey.length !== 32) { + return yield* Eff.fail( + new Bip32PublicKeyError({ + message: `Public key must be 32 bytes, got ${publicKey.length}` + }) + ) + } + if (chainCode.length !== 32) { + return yield* Eff.fail( + new Bip32PublicKeyError({ + message: `Chain code must be 32 bytes, got ${chainCode.length}` + }) + ) + } + + const result = new Uint8Array(64) + result.set(publicKey, 0) + result.set(chainCode, 32) + + return yield* Schema.decode(FromBytes)(result) + }).pipe( + Eff.mapError( + (cause) => + new Bip32PublicKeyError({ + message: "Failed to create Bip32PublicKey from bytes", + cause + }) + ) + ) + + /** + * Convert Bip32PublicKey to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (bip32PublicKey: Bip32PublicKey): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(bip32PublicKey), + (cause) => + new Bip32PublicKeyError({ + message: "Failed to encode Bip32PublicKey to bytes", + cause + }) + ) + + /** + * Derive a child public key using the specified index with Effect error handling. + * Only supports soft derivation (index < 0x80000000). + * + * @since 2.0.0 + * @category derivation + */ + export const deriveChild = ( + bip32PublicKey: Bip32PublicKey, + index: number + ): Eff.Effect => + Eff.gen(function* () { + if (index >= 0x80000000) { + return yield* Eff.fail( + new Bip32PublicKeyError({ + message: `Hardened derivation (index >= 0x80000000) not supported for public keys, got index ${index}` + }) + ) + } + + // Get the key bytes first + const keyBytes = yield* toBytes(bip32PublicKey) + const parentPublicKey = extractPublicKey(keyBytes) + const parentChainCode = extractChainCode(keyBytes) + + const derivedBytes = yield* Eff.try(() => { + // Serialize index in little-endian (V2 scheme) - CML compatible + const indexBytes = new Uint8Array(4) + indexBytes[0] = index & 0xff + indexBytes[1] = (index >>> 8) & 0xff + indexBytes[2] = (index >>> 16) & 0xff + indexBytes[3] = (index >>> 24) & 0xff + + // Create HMAC input for Z (soft derivation): tag(0x02) + public_key(32 bytes) + index(4 bytes) + const zTag = new Uint8Array([0x02]) // TAG_DERIVE_Z_SOFT + const zInput = new Uint8Array(1 + 32 + 4) + zInput.set(zTag, 0) + zInput.set(parentPublicKey, 1) + zInput.set(indexBytes, 33) + + // HMAC-SHA512 with chain code as key + const hmacZ = sodium.crypto_auth_hmacsha512(zInput, parentChainCode) + const z = new Uint8Array(hmacZ) + const zl = z.slice(0, 32) + + // For public key derivation, we need to compute: parentPublicKey + mul8(zl)*G + // where G is the Ed25519 base point and mul8(zl) applies the same 8-multiplication + // that's used in private key derivation (add_28_mul8_v2 algorithm) + + // Apply the same mul8 operation that private key derivation uses + // This is critical for compatibility - multiply first 28 bytes by 8 + const zl8 = new Uint8Array(32) + let carry = 0 + // First 28 bytes: zl[i] << 3 (multiply by 8) + for (let i = 0; i < 28; i++) { + const r = (zl[i] << 3) + carry + zl8[i] = r & 0xff + carry = r >> 8 + } + // Last 4 bytes: just carry (no multiplication) + for (let i = 28; i < 32; i++) { + const r = carry + zl8[i] = r & 0xff + carry = r >> 8 + } + + // Now compute zl8*G (scalar multiplication with base point using processed zl) + const zl8G = sodium.crypto_scalarmult_ed25519_base_noclamp(zl8) + + // Then add parentPublicKey + zl8G (point addition) + const childPublicKey = sodium.crypto_core_ed25519_add(parentPublicKey, zl8G) + + // Derive new chain code: tag(0x03) + public_key(32 bytes) + index(4 bytes) + const ccTag = new Uint8Array([0x03]) // TAG_DERIVE_CC_SOFT - corrected to 0x03 + const ccInput = new Uint8Array(1 + 32 + 4) + ccInput.set(ccTag, 0) + ccInput.set(parentPublicKey, 1) + ccInput.set(indexBytes, 33) + + const hmacCC = sodium.crypto_auth_hmacsha512(ccInput, parentChainCode) + const newChainCode = new Uint8Array(hmacCC).slice(32, 64) // Take right 32 bytes + + return { + publicKey: childPublicKey, + chainCode: newChainCode + } + }) + + // Create the new key bytes + const newKeyBytes = new Uint8Array(64) + newKeyBytes.set(derivedBytes.publicKey, 0) + newKeyBytes.set(derivedBytes.chainCode, 32) + + return yield* Schema.decode(FromBytes)(newKeyBytes) + }).pipe( + Eff.mapError( + (cause) => + new Bip32PublicKeyError({ + message: `Failed to derive child public key with index ${index}`, + cause + }) + ) + ) +} diff --git a/packages/evolution/src/Block.ts b/packages/evolution/src/Block.ts index 12b64e10..da751c1c 100644 --- a/packages/evolution/src/Block.ts +++ b/packages/evolution/src/Block.ts @@ -36,7 +36,7 @@ export class BlockClass extends Schema.TaggedClass()("Block", { // key: TransactionIndex.TransactionIndexSchema, // value: AuxiliaryData.AuxiliaryData, // }), - invalidTransactions: Schema.Array(TransactionIndex.TransactionIndexSchema) + invalidTransactions: Schema.Array(TransactionIndex.TransactionIndex) }) {} export type Block = Schema.Schema.Type diff --git a/packages/evolution/src/BlockBodyHash.ts b/packages/evolution/src/BlockBodyHash.ts index a7aa5b53..86f3cbd0 100644 --- a/packages/evolution/src/BlockBodyHash.ts +++ b/packages/evolution/src/BlockBodyHash.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes32 from "./Bytes32.js" -import { createEncoders } from "./Codec.js" /** * Error class for BlockBodyHash related operations. @@ -22,7 +21,7 @@ export class BlockBodyHashError extends Data.TaggedError("BlockBodyHashError")<{ * @since 2.0.0 * @category schemas */ -export const BlockBodyHash = pipe(Bytes32.HexSchema, Schema.brand("BlockBodyHash")).annotations({ +export const BlockBodyHash = Bytes32.HexSchema.pipe(Schema.brand("BlockBodyHash")).annotations({ identifier: "BlockBodyHash" }) @@ -42,6 +41,14 @@ export const FromHex = Schema.compose( identifier: "BlockBodyHash.Hex" }) +/** + * Smart constructor for BlockBodyHash that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = BlockBodyHash.make + /** * Check if two BlockBodyHash instances are equal. * @@ -51,26 +58,136 @@ export const FromHex = Schema.compose( export const equals = (a: BlockBodyHash, b: BlockBodyHash): boolean => a === b /** - * Generate a random BlockBodyHash. + * Check if the given value is a valid BlockBodyHash + * + * @since 2.0.0 + * @category predicates + */ +export const isBlockBodyHash = Schema.is(BlockBodyHash) + +/** + * FastCheck arbitrary for generating random BlockBodyHash instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes32.Bytes32_BYTES_LENGTH, - maxLength: Bytes32.Bytes32_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes32.HEX_LENGTH, + maxLength: Bytes32.HEX_LENGTH +}).map((hex) => hex as BlockBodyHash) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for BlockBodyHash encoding and decoding operations. + * Parse BlockBodyHash from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - BlockBodyHashError -) +export const fromBytes = (bytes: Uint8Array): BlockBodyHash => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse BlockBodyHash from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): BlockBodyHash => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode BlockBodyHash to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (blockBodyHash: BlockBodyHash): Uint8Array => Eff.runSync(Effect.toBytes(blockBodyHash)) + +/** + * Encode BlockBodyHash to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (blockBodyHash: BlockBodyHash): string => Eff.runSync(Effect.toHex(blockBodyHash)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse BlockBodyHash from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new BlockBodyHashError({ + message: "Failed to parse BlockBodyHash from bytes", + cause + }) + ) + ) + + /** + * Parse BlockBodyHash from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new BlockBodyHashError({ + message: "Failed to parse BlockBodyHash from hex", + cause + }) + ) + ) + + /** + * Encode BlockBodyHash to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (blockBodyHash: BlockBodyHash): Eff.Effect => + Schema.encode(FromBytes)(blockBodyHash).pipe( + Eff.mapError( + (cause) => + new BlockBodyHashError({ + message: "Failed to encode BlockBodyHash to bytes", + cause + }) + ) + ) + + /** + * Encode BlockBodyHash to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (blockBodyHash: BlockBodyHash): Eff.Effect => + Schema.encode(FromHex)(blockBodyHash).pipe( + Eff.mapError( + (cause) => + new BlockBodyHashError({ + message: "Failed to encode BlockBodyHash to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/BlockHeaderHash.ts b/packages/evolution/src/BlockHeaderHash.ts index f741c3c4..3525fa5b 100644 --- a/packages/evolution/src/BlockHeaderHash.ts +++ b/packages/evolution/src/BlockHeaderHash.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes32 from "./Bytes32.js" -import { createEncoders } from "./Codec.js" /** * Error class for BlockHeaderHash related operations. @@ -22,7 +21,7 @@ export class BlockHeaderHashError extends Data.TaggedError("BlockHeaderHashError * @since 2.0.0 * @category schemas */ -export const BlockHeaderHash = pipe(Bytes32.HexSchema, Schema.brand("BlockHeaderHash")).annotations({ +export const BlockHeaderHash = Bytes32.HexSchema.pipe(Schema.brand("BlockHeaderHash")).annotations({ identifier: "BlockHeaderHash" }) @@ -42,6 +41,14 @@ export const FromHex = Schema.compose( identifier: "BlockHeaderHash.Hex" }) +/** + * Smart constructor for BlockHeaderHash that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = BlockHeaderHash.make + /** * Check if two BlockHeaderHash instances are equal. * @@ -51,26 +58,136 @@ export const FromHex = Schema.compose( export const equals = (a: BlockHeaderHash, b: BlockHeaderHash): boolean => a === b /** - * Generate a random BlockHeaderHash. + * Check if the given value is a valid BlockHeaderHash + * + * @since 2.0.0 + * @category predicates + */ +export const isBlockHeaderHash = Schema.is(BlockHeaderHash) + +/** + * FastCheck arbitrary for generating random BlockHeaderHash instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes32.Bytes32_BYTES_LENGTH, - maxLength: Bytes32.Bytes32_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes32.HEX_LENGTH, + maxLength: Bytes32.HEX_LENGTH +}).map((hex) => hex as BlockHeaderHash) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for BlockHeaderHash encoding and decoding operations. + * Parse BlockHeaderHash from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - BlockHeaderHashError -) +export const fromBytes = (bytes: Uint8Array): BlockHeaderHash => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse BlockHeaderHash from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): BlockHeaderHash => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode BlockHeaderHash to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (blockHeaderHash: BlockHeaderHash): Uint8Array => Eff.runSync(Effect.toBytes(blockHeaderHash)) + +/** + * Encode BlockHeaderHash to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (blockHeaderHash: BlockHeaderHash): string => Eff.runSync(Effect.toHex(blockHeaderHash)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse BlockHeaderHash from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new BlockHeaderHashError({ + message: "Failed to parse BlockHeaderHash from bytes", + cause + }) + ) + ) + + /** + * Parse BlockHeaderHash from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new BlockHeaderHashError({ + message: "Failed to parse BlockHeaderHash from hex", + cause + }) + ) + ) + + /** + * Encode BlockHeaderHash to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (blockHeaderHash: BlockHeaderHash): Eff.Effect => + Schema.encode(FromBytes)(blockHeaderHash).pipe( + Eff.mapError( + (cause) => + new BlockHeaderHashError({ + message: "Failed to encode BlockHeaderHash to bytes", + cause + }) + ) + ) + + /** + * Encode BlockHeaderHash to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (blockHeaderHash: BlockHeaderHash): Eff.Effect => + Schema.encode(FromHex)(blockHeaderHash).pipe( + Eff.mapError( + (cause) => + new BlockHeaderHashError({ + message: "Failed to encode BlockHeaderHash to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/ByronAddress.ts b/packages/evolution/src/ByronAddress.ts index 3e697f2f..b6d54982 100644 --- a/packages/evolution/src/ByronAddress.ts +++ b/packages/evolution/src/ByronAddress.ts @@ -1,8 +1,19 @@ -import { Schema } from "effect" +import { Data, Effect as Eff, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as NetworkId from "./NetworkId.js" +/** + * Error class for ByronAddress related operations. + * + * @since 2.0.0 + * @category errors + */ +export class ByronAddressError extends Data.TaggedError("ByronAddressError")<{ + message?: string + cause?: unknown +}> {} + /** * Byron legacy address format * @@ -22,16 +33,39 @@ export class ByronAddress extends Schema.TaggedClass("ByronAddress } } -// /** -// * Byron legacy address has limited support -// * @since 2.0.0 -// */ -export const BytesSchema = Schema.transform(Schema.Uint8ArrayFromSelf, ByronAddress, { +/** + * Schema for encoding/decoding Byron addresses as bytes. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.transformOrFail(Schema.Uint8ArrayFromSelf, ByronAddress, { strict: true, - encode: (_, toA) => Bytes.Codec.Encode.bytes(toA.bytes), - decode: (_, fromA) => - new ByronAddress({ - networkId: NetworkId.NetworkId.make(0), - bytes: Bytes.Codec.Decode.bytes(fromA) + encode: (_, __, ___, toA) => ParseResult.decode(Bytes.FromHex)(toA.bytes), + decode: (_, __, ast, fromA) => + Eff.gen(function* () { + const hexString = yield* ParseResult.encode(Bytes.FromHex)(fromA) + return new ByronAddress({ + networkId: NetworkId.NetworkId.make(0), + bytes: hexString + }) }) }) + +/** + * Schema for encoding/decoding Byron addresses as hex strings. + * + * @since 2.0.0 + * @category schemas + */ +export const FromHex = Schema.compose(Bytes.FromHex, BytesSchema) + +/** + * Checks if two Byron addresses are equal. + * + * @since 2.0.0 + * @category utils + */ +export const equals = (a: ByronAddress, b: ByronAddress): boolean => { + return a.networkId === b.networkId && a.bytes === b.bytes +} diff --git a/packages/evolution/src/Bytes.ts b/packages/evolution/src/Bytes.ts index 9ca73a8c..05c4cc83 100644 --- a/packages/evolution/src/Bytes.ts +++ b/packages/evolution/src/Bytes.ts @@ -1,7 +1,5 @@ import { Data, Schema } from "effect" -import * as _Codec from "./Codec.js" - export class BytesError extends Data.TaggedError("BytesError")<{ message?: string cause?: unknown @@ -193,11 +191,3 @@ export const FromBytesLenient = Schema.transform(Schema.Uint8ArrayFromSelf, HexL }).annotations({ identifier: "Bytes.FromBytesLenient" }) - -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - bytesLenient: FromBytesLenient - }, - BytesError -) diff --git a/packages/evolution/src/Bytes128.ts b/packages/evolution/src/Bytes128.ts new file mode 100644 index 00000000..b1ab280f --- /dev/null +++ b/packages/evolution/src/Bytes128.ts @@ -0,0 +1,118 @@ +import { Data, Effect as Eff, Schema } from "effect" + +import * as Bytes from "./Bytes.js" + +export class Bytes128Error extends Data.TaggedError("Bytes128Error")<{ + message?: string + cause?: unknown +}> {} + +export const BYTES_LENGTH = 128 +export const HEX_LENGTH = 256 + +/** + * Schema for Bytes128 bytes with 128-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes128.Bytes", + title: "128-byte Array", + description: "A Uint8Array containing exactly 128 bytes", + message: (issue) => + `Bytes128 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(128).fill(0)] +}) + +/** + * Schema for Bytes128 hex strings with 256-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes128.Hex", + title: "128-byte Hex String", + description: "A hexadecimal string representing exactly 128 bytes (256 characters)", + message: (issue) => `Bytes128 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(256)] +}) + +/** + * Schema transformer for Bytes128 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes128-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const FromBytes = Schema.transform(BytesSchema, HexSchema, { + strict: true, + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes128.FromBytes", + title: "Bytes128 from Uint8Array", + description: "Transforms a 128-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" +}) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Bytes128 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes128Error({ + message: "Failed to parse Bytes128 from bytes", + cause + }) + ) + + /** + * Convert Bytes128 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes128Error({ + message: "Failed to encode Bytes128 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes128 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Bytes128 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) diff --git a/packages/evolution/src/Bytes16.ts b/packages/evolution/src/Bytes16.ts index d5323ba2..c247d09d 100644 --- a/packages/evolution/src/Bytes16.ts +++ b/packages/evolution/src/Bytes16.ts @@ -1,36 +1,135 @@ -import { pipe, Schema } from "effect" +import { Data, Either as E, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes16Error extends Data.TaggedError("Bytes16Error")<{ + message?: string + cause?: unknown +}> {} + export const BYTES_LENGTH = 16 export const HEX_LENGTH = 32 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => - `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${(issue.actual as Uint8Array).length}`, - identifier: "Bytes16.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => - `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${(issue.actual as string).length}`, - identifier: "Bytes16.Hex" - }) -) - -export const FromHex = Schema.transform(HexSchema, BytesSchema, { - strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) +/** + * Schema for Bytes16 bytes with 16-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes16.Bytes", + title: "16-byte Array", + description: "A Uint8Array containing exactly 16 bytes", + message: (issue) => `Bytes16 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(16).fill(0)] +}) + +/** + * Schema for Bytes16 hex strings with 32-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes16.Hex", + title: "16-byte Hex String", + description: "A hexadecimal string representing exactly 16 bytes (32 characters)", + message: (issue) => `Bytes16 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(32)] }) +/** + * Schema transformer for Bytes16 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes16-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - encode: (toI) => Bytes.Codec.Encode.bytes(toI), - decode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes16.FromBytes", + title: "Bytes16 from Uint8Array", + description: "Transforms a 16-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Either { + /** + * Parse Bytes16 from raw bytes using Either error handling. + */ + export const fromBytes = (bytes: Uint8Array): E.Either => + E.mapLeft( + Schema.decodeEither(FromBytes)(bytes), + (cause) => + new Bytes16Error({ + message: "Failed to parse Bytes16 from bytes", + cause + }) + ) + + /** + * Convert Bytes16 hex to raw bytes using Either error handling. + */ + export const toBytes = (hex: string): E.Either => + E.mapLeft( + Schema.encodeEither(FromBytes)(hex), + (cause) => + new Bytes16Error({ + message: "Failed to encode Bytes16 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes16 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => { + try { + return Schema.decodeSync(FromBytes)(bytes) + } catch (cause) { + throw new Bytes16Error({ + message: "Failed to parse Bytes16 from bytes", + cause + }) + } +} + +/** + * Convert Bytes16 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => { + try { + return Schema.encodeSync(FromBytes)(hex) + } catch (cause) { + throw new Bytes16Error({ + message: "Failed to encode Bytes16 to bytes", + cause + }) + } +} diff --git a/packages/evolution/src/Bytes29.ts b/packages/evolution/src/Bytes29.ts index 15acb635..198fa5c9 100644 --- a/packages/evolution/src/Bytes29.ts +++ b/packages/evolution/src/Bytes29.ts @@ -1,28 +1,117 @@ -import { pipe, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes29Error extends Data.TaggedError("Bytes29Error")<{ + message?: string + cause?: unknown +}> {} + export const BYTES_LENGTH = 29 export const HEX_LENGTH = 58 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes29.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes29.Hex" - }) -) - -export const BytesFromHex = Schema.transform(HexSchema, BytesSchema, { +/** + * Schema for Bytes29 bytes with 29-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes29.Bytes", + title: "29-byte Array", + description: "A Uint8Array containing exactly 29 bytes", + message: (issue) => `Bytes29 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(29).fill(0)] +}) + +/** + * Schema for Bytes29 hex strings with 58-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes29.Hex", + title: "29-byte Hex String", + description: "A hexadecimal string representing exactly 29 bytes (58 characters)", + message: (issue) => `Bytes29 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(58)] +}) + +/** + * Schema transformer for Bytes29 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes29-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes29.FromBytes", + title: "Bytes29 from Uint8Array", + description: "Transforms a 29-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Bytes29 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes29Error({ + message: "Failed to parse Bytes29 from bytes", + cause + }) + ) + + /** + * Convert Bytes29 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes29Error({ + message: "Failed to encode Bytes29 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes29 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Bytes29 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) diff --git a/packages/evolution/src/Bytes32.ts b/packages/evolution/src/Bytes32.ts index 49d78995..95fd1627 100644 --- a/packages/evolution/src/Bytes32.ts +++ b/packages/evolution/src/Bytes32.ts @@ -1,7 +1,6 @@ -import { Data, Schema } from "effect" +import { Data, Either as E, Schema } from "effect" import * as Bytes from "./Bytes.js" -import * as _Codec from "./Codec.js" export class Bytes32Error extends Data.TaggedError("Bytes32Error")<{ message?: string @@ -9,8 +8,8 @@ export class Bytes32Error extends Data.TaggedError("Bytes32Error")<{ }> {} // Add constants following the style guide -export const Bytes32_BYTES_LENGTH = 32 -export const Bytes32_HEX_LENGTH = 64 +export const BYTES_LENGTH = 32 +export const HEX_LENGTH = 64 /** * Schema for Bytes32 bytes with 32-byte length validation. @@ -18,12 +17,12 @@ export const Bytes32_HEX_LENGTH = 64 * @since 2.0.0 * @category schemas */ -export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe( - Schema.filter((a) => a.length === Bytes32_BYTES_LENGTH) -).annotations({ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ identifier: "Bytes32.Bytes", - message: (issue) => - `Bytes32 bytes must be exactly ${Bytes32_BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}` + title: "32-byte Array", + description: "A Uint8Array containing exactly 32 bytes", + message: (issue) => `Bytes32 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(32).fill(0)] }) /** @@ -32,10 +31,12 @@ export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe( * @since 2.0.0 * @category schemas */ -export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === Bytes32_HEX_LENGTH)).annotations({ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ identifier: "Bytes32.Hex", - message: (issue) => - `Bytes32 hex must be exactly ${Bytes32_HEX_LENGTH} characters, got ${(issue.actual as string).length}` + title: "32-byte Hex String", + description: "A hexadecimal string representing exactly 32 bytes (64 characters)", + message: (issue) => `Bytes32 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(64)] }) /** @@ -46,10 +47,10 @@ export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === * @category schemas */ export const VariableBytesSchema = Schema.Uint8ArrayFromSelf.pipe( - Schema.filter((a) => a.length >= 0 && a.length <= Bytes32_BYTES_LENGTH) + Schema.filter((a) => a.length >= 0 && a.length <= BYTES_LENGTH) ).annotations({ message: (issue) => - `must be a byte array of length 0 to ${Bytes32_BYTES_LENGTH}, but got ${(issue.actual as Uint8Array).length}`, + `must be a byte array of length 0 to ${BYTES_LENGTH}, but got ${(issue.actual as Uint8Array).length}`, identifier: "Bytes32.VariableBytes" }) @@ -61,10 +62,9 @@ export const VariableBytesSchema = Schema.Uint8ArrayFromSelf.pipe( * @category schemas */ export const VariableHexSchema = Bytes.HexSchema.pipe( - Schema.filter((a) => a.length >= 0 && a.length <= Bytes32_HEX_LENGTH) + Schema.filter((a) => a.length >= 0 && a.length <= HEX_LENGTH) ).annotations({ - message: (issue) => - `must be a hex string of length 0 to ${Bytes32_HEX_LENGTH}, but got ${(issue.actual as string).length}`, + message: (issue) => `must be a hex string of length 0 to ${HEX_LENGTH}, but got ${(issue.actual as string).length}`, identifier: "Bytes32.VariableHex" }) @@ -91,6 +91,11 @@ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { } return array } +}).annotations({ + identifier: "Bytes32.FromBytes", + title: "Bytes32 from Uint8Array", + description: "Transforms a 32-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) /** @@ -117,18 +122,135 @@ export const FromVariableBytes = Schema.transform(VariableBytesSchema, VariableH } return array } +}).annotations({ + identifier: "Bytes32.FromVariableBytes", + title: "Variable Bytes32 from Uint8Array", + description: "Transforms variable-length byte arrays (0-32 bytes) to hex strings (0-64 chars)", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) /** - * Codec for Bytes32 encoding and decoding operations. + * Either namespace containing composable operations that can fail. + * All functions return Either objects for proper error handling and composition. + */ +export namespace Either { + /** + * Parse Bytes32 from raw bytes using Either error handling. + */ + export const fromBytes = (bytes: Uint8Array): E.Either => + E.mapLeft( + Schema.decodeEither(FromBytes)(bytes), + (cause) => + new Bytes32Error({ + message: "Failed to parse Bytes32 from bytes", + cause + }) + ) + + /** + * Convert Bytes32 hex to raw bytes using Either error handling. + */ + export const toBytes = (hex: string): E.Either => + E.mapLeft( + Schema.encodeEither(FromBytes)(hex), + (cause) => + new Bytes32Error({ + message: "Failed to encode Bytes32 to bytes", + cause + }) + ) + + /** + * Parse variable-length data from raw bytes using Either error handling. + */ + export const fromVariableBytes = (bytes: Uint8Array): E.Either => + E.mapLeft( + Schema.decodeEither(FromVariableBytes)(bytes), + (cause) => + new Bytes32Error({ + message: "Failed to parse variable Bytes32 from bytes", + cause + }) + ) + + /** + * Convert variable-length hex to raw bytes using Either error handling. + */ + export const toVariableBytes = (hex: string): E.Either => + E.mapLeft( + Schema.encodeEither(FromVariableBytes)(hex), + (cause) => + new Bytes32Error({ + message: "Failed to encode variable Bytes32 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes32 from raw bytes (unsafe - throws on error). * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - variableBytes: FromVariableBytes - }, - Bytes32Error -) +export const fromBytes = (bytes: Uint8Array): string => { + try { + return Schema.decodeSync(FromBytes)(bytes) + } catch (cause) { + throw new Bytes32Error({ + message: "Failed to parse Bytes32 from bytes", + cause + }) + } +} + +/** + * Convert Bytes32 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => { + try { + return Schema.encodeSync(FromBytes)(hex) + } catch (cause) { + throw new Bytes32Error({ + message: "Failed to encode Bytes32 to bytes", + cause + }) + } +} + +/** + * Parse variable-length data from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromVariableBytes = (bytes: Uint8Array): string => { + try { + return Schema.decodeSync(FromVariableBytes)(bytes) + } catch (cause) { + throw new Bytes32Error({ + message: "Failed to parse variable Bytes32 from bytes", + cause + }) + } +} + +/** + * Convert variable-length hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toVariableBytes = (hex: string): Uint8Array => { + try { + return Schema.encodeSync(FromVariableBytes)(hex) + } catch (cause) { + throw new Bytes32Error({ + message: "Failed to encode variable Bytes32 to bytes", + cause + }) + } +} diff --git a/packages/evolution/src/Bytes4.ts b/packages/evolution/src/Bytes4.ts index e8f34c94..7dc3c951 100644 --- a/packages/evolution/src/Bytes4.ts +++ b/packages/evolution/src/Bytes4.ts @@ -1,34 +1,135 @@ -import { pipe, Schema } from "effect" +import { Data, Either as E, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes4Error extends Data.TaggedError("Bytes4Error")<{ + message?: string + cause?: unknown +}> {} + export const BYTES_LENGTH = 4 export const HEX_LENGTH = 8 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes4.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes4.Hex" - }) -) - -export const FromHex = Schema.transform(HexSchema, BytesSchema, { - strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) +/** + * Schema for Bytes4 bytes with 4-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes4.Bytes", + title: "4-byte Array", + description: "A Uint8Array containing exactly 4 bytes", + message: (issue) => `Bytes4 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(4).fill(0)] +}) + +/** + * Schema for Bytes4 hex strings with 8-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes4.Hex", + title: "4-byte Hex String", + description: "A hexadecimal string representing exactly 4 bytes (8 characters)", + message: (issue) => `Bytes4 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(8)] }) +/** + * Schema transformer for Bytes4 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes4-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - encode: (toI) => Bytes.Codec.Encode.bytes(toI), - decode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes4.FromBytes", + title: "Bytes4 from Uint8Array", + description: "Transforms a 4-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Either { + /** + * Parse Bytes4 from raw bytes using Either error handling. + */ + export const fromBytes = (bytes: Uint8Array): E.Either => + E.mapLeft( + Schema.decodeEither(FromBytes)(bytes), + (cause) => + new Bytes4Error({ + message: "Failed to parse Bytes4 from bytes", + cause + }) + ) + + /** + * Convert Bytes4 hex to raw bytes using Either error handling. + */ + export const toBytes = (hex: string): E.Either => + E.mapLeft( + Schema.encodeEither(FromBytes)(hex), + (cause) => + new Bytes4Error({ + message: "Failed to encode Bytes4 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes4 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => { + try { + return Schema.decodeSync(FromBytes)(bytes) + } catch (cause) { + throw new Bytes4Error({ + message: "Failed to parse Bytes4 from bytes", + cause + }) + } +} + +/** + * Convert Bytes4 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => { + try { + return Schema.encodeSync(FromBytes)(hex) + } catch (cause) { + throw new Bytes4Error({ + message: "Failed to encode Bytes4 to bytes", + cause + }) + } +} diff --git a/packages/evolution/src/Bytes448.ts b/packages/evolution/src/Bytes448.ts index 837fc877..d92ed09a 100644 --- a/packages/evolution/src/Bytes448.ts +++ b/packages/evolution/src/Bytes448.ts @@ -1,34 +1,118 @@ -import { pipe, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes448Error extends Data.TaggedError("Bytes448Error")<{ + message?: string + cause?: unknown +}> {} + export const BYTES_LENGTH = 448 export const HEX_LENGTH = 896 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes448.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes448.Hex" - }) -) - -export const FromHex = Schema.transform(HexSchema, BytesSchema, { - strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) +/** + * Schema for Bytes448 bytes with 448-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes448.Bytes", + title: "448-byte Array", + description: "A Uint8Array containing exactly 448 bytes", + message: (issue) => + `Bytes448 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(448).fill(0)] +}) + +/** + * Schema for Bytes448 hex strings with 896-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes448.Hex", + title: "448-byte Hex String", + description: "A hexadecimal string representing exactly 448 bytes (896 characters)", + message: (issue) => `Bytes448 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(896)] }) +/** + * Schema transformer for Bytes448 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes448-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - encode: (toI) => Bytes.Codec.Encode.bytes(toI), - decode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes448.FromBytes", + title: "Bytes448 from Uint8Array", + description: "Transforms a 448-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Bytes448 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes448Error({ + message: "Failed to parse Bytes448 from bytes", + cause + }) + ) + + /** + * Convert Bytes448 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes448Error({ + message: "Failed to encode Bytes448 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes448 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Bytes448 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) diff --git a/packages/evolution/src/Bytes57.ts b/packages/evolution/src/Bytes57.ts index 405db739..9bfb6020 100644 --- a/packages/evolution/src/Bytes57.ts +++ b/packages/evolution/src/Bytes57.ts @@ -1,28 +1,117 @@ -import { pipe, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes57Error extends Data.TaggedError("Bytes57Error")<{ + message?: string + cause?: unknown +}> {} + export const BYTES_LENGTH = 57 export const HEX_LENGTH = 114 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes57.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes57.Hex" - }) -) - -export const BytesFromHex = Schema.transform(HexSchema, BytesSchema, { +/** + * Schema for Bytes57 bytes with 57-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes57.Bytes", + title: "57-byte Array", + description: "A Uint8Array containing exactly 57 bytes", + message: (issue) => `Bytes57 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(57).fill(0)] +}) + +/** + * Schema for Bytes57 hex strings with 114-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes57.Hex", + title: "57-byte Hex String", + description: "A hexadecimal string representing exactly 57 bytes (114 characters)", + message: (issue) => `Bytes57 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(114)] +}) + +/** + * Schema transformer for Bytes57 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes57-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes57.FromBytes", + title: "Bytes57 from Uint8Array", + description: "Transforms a 57-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Bytes57 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes57Error({ + message: "Failed to parse Bytes57 from bytes", + cause + }) + ) + + /** + * Convert Bytes57 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes57Error({ + message: "Failed to encode Bytes57 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes57 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Bytes57 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) diff --git a/packages/evolution/src/Bytes64.ts b/packages/evolution/src/Bytes64.ts index 02301aed..b157cd7d 100644 --- a/packages/evolution/src/Bytes64.ts +++ b/packages/evolution/src/Bytes64.ts @@ -1,34 +1,148 @@ -import { pipe, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes64Error extends Data.TaggedError("Bytes64Error")<{ + message?: string + cause?: unknown +}> {} + +// Add constants following the style guide export const BYTES_LENGTH = 64 export const HEX_LENGTH = 128 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes64.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes64.Hex" - }) -) - -export const FromHex = Schema.transform(HexSchema, BytesSchema, { - strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) +/** + * Schema for Bytes64 bytes with 64-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes64.Bytes", + title: "64-byte Array", + description: "A Uint8Array containing exactly 64 bytes", + message: (issue) => `Bytes64 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(64).fill(0)] +}) + +/** + * Schema for Bytes64 hex strings with 128-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes64.Hex", + title: "64-byte Hex String", + description: "A hexadecimal string representing exactly 64 bytes (128 characters)", + message: (issue) => `Bytes64 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(128)] }) +/** + * Schema transformer for Bytes64 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes64-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - encode: (toI) => Bytes.Codec.Encode.bytes(toI), - decode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes64.FromBytes", + title: "Bytes64 from Uint8Array", + description: "Transforms a 64-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse Bytes64 from raw bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => { + try { + return Schema.decodeSync(FromBytes)(bytes) + } catch (cause) { + throw new Bytes64Error({ + message: "Failed to parse Bytes64 from bytes", + cause + }) + } +} + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert Bytes64 hex to raw bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse Bytes64 from raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes64Error({ + message: "Failed to parse Bytes64 from bytes", + cause + }) + ) + + /** + * Convert Bytes64 hex to raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes64Error({ + message: "Failed to encode Bytes64 to bytes", + cause + }) + ) +} diff --git a/packages/evolution/src/Bytes80.ts b/packages/evolution/src/Bytes80.ts index 10f415be..fdfcadcd 100644 --- a/packages/evolution/src/Bytes80.ts +++ b/packages/evolution/src/Bytes80.ts @@ -1,34 +1,117 @@ -import { pipe, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" import * as Bytes from "./Bytes.js" +export class Bytes80Error extends Data.TaggedError("Bytes80Error")<{ + message?: string + cause?: unknown +}> {} + export const BYTES_LENGTH = 80 export const HEX_LENGTH = 160 -export const BytesSchema = pipe( - Schema.Uint8ArrayFromSelf, - Schema.filter((a) => a.length === BYTES_LENGTH, { - message: (issue) => `${issue.actual} must be a byte array of length ${BYTES_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes80.Bytes" - }) -) - -export const HexSchema = pipe( - Bytes.HexSchema, - Schema.filter((a) => a.length === HEX_LENGTH, { - message: (issue) => `${issue.actual} must be a hex string of length ${HEX_LENGTH}, but got ${issue.actual}`, - identifier: "Bytes80.Hex" - }) -) - -export const FromHex = Schema.transform(HexSchema, BytesSchema, { - strict: true, - decode: (toI) => Bytes.Codec.Encode.bytes(toI), - encode: (fromA) => Bytes.Codec.Decode.bytes(fromA) +/** + * Schema for Bytes80 bytes with 80-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes80.Bytes", + title: "80-byte Array", + description: "A Uint8Array containing exactly 80 bytes", + message: (issue) => `Bytes80 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(80).fill(0)] +}) + +/** + * Schema for Bytes80 hex strings with 160-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes80.Hex", + title: "80-byte Hex String", + description: "A hexadecimal string representing exactly 80 bytes (160 characters)", + message: (issue) => `Bytes80 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(160)] }) +/** + * Schema transformer for Bytes80 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes80-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { strict: true, - encode: (toI) => Bytes.Codec.Encode.bytes(toI), - decode: (fromA) => Bytes.Codec.Decode.bytes(fromA) + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes80.FromBytes", + title: "Bytes80 from Uint8Array", + description: "Transforms a 80-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Bytes80 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes80Error({ + message: "Failed to parse Bytes80 from bytes", + cause + }) + ) + + /** + * Convert Bytes80 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes80Error({ + message: "Failed to encode Bytes80 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes80 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Bytes80 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) diff --git a/packages/evolution/src/Bytes96.ts b/packages/evolution/src/Bytes96.ts new file mode 100644 index 00000000..ab98c685 --- /dev/null +++ b/packages/evolution/src/Bytes96.ts @@ -0,0 +1,117 @@ +import { Data, Effect as Eff, Schema } from "effect" + +import * as Bytes from "./Bytes.js" + +export class Bytes96Error extends Data.TaggedError("Bytes96Error")<{ + message?: string + cause?: unknown +}> {} + +export const BYTES_LENGTH = 96 +export const HEX_LENGTH = 192 + +/** + * Schema for Bytes96 bytes with 96-byte length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ + identifier: "Bytes96.Bytes", + title: "96-byte Array", + description: "A Uint8Array containing exactly 96 bytes", + message: (issue) => `Bytes96 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(96).fill(0)] +}) + +/** + * Schema for Bytes96 hex strings with 192-character length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Bytes96.Hex", + title: "96-byte Hex String", + description: "A hexadecimal string representing exactly 96 bytes (192 characters)", + message: (issue) => `Bytes96 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(192)] +}) + +/** + * Schema transformer for Bytes96 that converts between hex strings and byte arrays. + * Like Bytes.BytesSchema but with Bytes96-specific length validation. + * + * @since 2.0.0 + * @category schemas + */ +export const FromBytes = Schema.transform(BytesSchema, HexSchema, { + strict: true, + decode: (toA) => { + let hex = "" + for (let i = 0; i < toA.length; i++) { + hex += toA[i].toString(16).padStart(2, "0") + } + return hex + }, + encode: (fromA) => { + const array = new Uint8Array(fromA.length / 2) + for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { + array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) + } + return array + } +}).annotations({ + identifier: "Bytes96.FromBytes", + title: "Bytes96 from Uint8Array", + description: "Transforms a 96-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" +}) + +/** + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Bytes96 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Bytes96Error({ + message: "Failed to parse Bytes96 from bytes", + cause + }) + ) + + /** + * Convert Bytes96 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Bytes96Error({ + message: "Failed to encode Bytes96 to bytes", + cause + }) + ) +} + +/** + * Parse Bytes96 from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Bytes96 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 82e1d8e7..4becb15c 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -1,7 +1,6 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" -import * as _Codec from "./Codec.js" /** * Error class for CBOR value operations @@ -67,6 +66,7 @@ export const CBOR_SIMPLE = { export type CodecOptions = | { readonly mode: "canonical" + readonly mapsAsObjects?: boolean } | { readonly mode: "custom" @@ -75,6 +75,7 @@ export type CodecOptions = readonly useDefiniteForEmpty: boolean readonly sortMapKeys: boolean readonly useMinimalEncoding: boolean + readonly mapsAsObjects?: boolean } /** @@ -93,13 +94,56 @@ export const CANONICAL_OPTIONS: CodecOptions = { * @since 1.0.0 * @category constants */ -export const DEFAULT_OPTIONS: CodecOptions = { +export const CML_DEFAULT_OPTIONS: CodecOptions = { + mode: "custom", + useIndefiniteArrays: false, + useIndefiniteMaps: false, + useDefiniteForEmpty: true, + sortMapKeys: false, + useMinimalEncoding: true, + mapsAsObjects: false +} as const + +/** + * Default CBOR encoding option for Data + * + * @since 1.0.0 + * @category constants + */ +export const CML_DATA_DEFAULT_OPTIONS: CodecOptions = { mode: "custom", useIndefiniteArrays: true, useIndefiniteMaps: true, useDefiniteForEmpty: true, sortMapKeys: false, - useMinimalEncoding: true + useMinimalEncoding: true, + mapsAsObjects: false +} as const + +/** + * CBOR encoding options that return objects instead of Maps for Schema.Struct compatibility + * + * @since 2.0.0 + * @category constants + */ +export const STRUCT_FRIENDLY_OPTIONS: CodecOptions = { + mode: "custom", + useIndefiniteArrays: false, + useIndefiniteMaps: false, + useDefiniteForEmpty: true, + sortMapKeys: false, + useMinimalEncoding: true, + mapsAsObjects: true +} as const + +const DEFAULT_OPTIONS: CodecOptions = { + mode: "custom", + useIndefiniteArrays: false, + useIndefiniteMaps: false, + useDefiniteForEmpty: true, + sortMapKeys: false, + useMinimalEncoding: true, + mapsAsObjects: false } as const /** @@ -114,13 +158,38 @@ export type CBOR = | string // text strings | ReadonlyArray // arrays | ReadonlyMap // maps - | { readonly [key: string]: CBOR } // record alternative to maps - | Tag // tagged values + | { readonly [key: string | number]: CBOR } // record alternative to maps + | { _tag: "Tag"; tag: number; value: CBOR } // tagged values | boolean // boolean values | null // null value | undefined // undefined value | number // floating point numbers +/** + * **Record vs Map Key Ordering** + * + * Records `{ readonly [key: string | number]: CBOR }` follow JavaScript object property enumeration rules: + * 1. **Integer-like strings in ascending numeric order** (e.g., "0", "1", "42", "999") + * 2. **Other strings in insertion order** (e.g., "text", "key1", "key2") + * + * Maps `ReadonlyMap` preserve insertion order for all key types. + * + * **Example:** + * ```typescript + * // Map preserves insertion order: ["text", 42n, 999n] + * const map = new Map([["text", "a"], [42n, "b"], [999n, "c"]]) + * + * // Record follows JS enumeration: [42, 999, "text"] + * const record = { text: "a", 42: "b", 999: "c" } + * ``` + * + * **Recommendation:** Use Maps for consistent insertion order with mixed key types, + * or use canonical encoding to eliminate ordering differences. + * + * @since 1.0.0 + * @category model + */ + /** * CBOR Value schema definitions for each major type * @@ -145,8 +214,8 @@ export const isArray = Schema.is(ArraySchema) // Map (Major Type 5) export const MapSchema = Schema.ReadonlyMapFromSelf({ - key: Schema.suspend(() => CBORSchema), - value: Schema.suspend(() => CBORSchema) + key: Schema.suspend((): Schema.Schema => CBORSchema), + value: Schema.suspend((): Schema.Schema => CBORSchema) }) export const isMap = Schema.is(MapSchema) @@ -155,17 +224,27 @@ export const isMap = Schema.is(MapSchema) // Provides a Record alternative to ReadonlyMap // for applications that prefer object-based map representations export const RecordSchema = Schema.Record({ - key: Schema.String, - value: Schema.suspend(() => CBORSchema) + key: Schema.String, // Keep only string keys for Schema compatibility + value: Schema.suspend((): Schema.Schema => CBORSchema) }) export const isRecord = Schema.is(RecordSchema) // Tag (Major Type 6) -export class Tag extends Schema.TaggedClass("Tag")("Tag", { +export const Tag = Schema.TaggedStruct("Tag", { tag: Schema.Number, - value: Schema.suspend(() => CBORSchema) -}) {} + value: Schema.suspend((): Schema.Schema => CBORSchema) +}) + +export const tag = >(tag: T, value: C) => + Schema.TaggedStruct("Tag", { + tag: Schema.Literal(tag), + value + }) + +// Map function to create a schema for CBOR maps with specific key and value types +export const map = (key: Schema.Schema, value: Schema.Schema) => + Schema.ReadonlyMapFromSelf({ key, value }) export const isTag = Schema.is(Tag) @@ -253,7 +332,7 @@ export const match = ( if (value instanceof Map) { return patterns.map(value) } - if (value instanceof Tag) { + if (isTag(value)) { return patterns.tag(value.tag, value.value) } if ( @@ -284,8 +363,8 @@ export const match = ( } // Internal encoding function used by Schema.transformOrFail -const internalEncode = (value: CBOR, options: CodecOptions = DEFAULT_OPTIONS): Effect.Effect => - Effect.gen(function* () { +const internalEncode = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPTIONS): Eff.Effect => + Eff.gen(function* () { if (typeof value === "bigint") { if (value >= 0n) { return yield* encodeUint(value, options) @@ -305,7 +384,7 @@ const internalEncode = (value: CBOR, options: CodecOptions = DEFAULT_OPTIONS): E if (value instanceof Map) { return yield* encodeMap(value, options) } - if (value instanceof Tag) { + if (isTag(value)) { return yield* encodeTag(value.tag, value.value, options) } if ( @@ -316,7 +395,7 @@ const internalEncode = (value: CBOR, options: CodecOptions = DEFAULT_OPTIONS): E !(value instanceof Uint8Array) && !(value instanceof Tag) ) { - return yield* encodeRecord(value as { readonly [key: string]: CBOR }, options) + return yield* encodeRecord(value as { readonly [key: string | number]: CBOR }, options) } if (typeof value === "boolean" || value === null || value === undefined) { return yield* encodeSimple(value) @@ -331,13 +410,16 @@ const internalEncode = (value: CBOR, options: CodecOptions = DEFAULT_OPTIONS): E }) // Internal decoding function used by Schema.transformOrFail -const internalDecode = (data: Uint8Array): Effect.Effect => - Effect.gen(function* () { +export const internalDecode = ( + data: Uint8Array, + options: CodecOptions = DEFAULT_OPTIONS +): Eff.Effect => + Eff.gen(function* () { if (data.length === 0) { return yield* new CBORError({ message: "Empty CBOR data" }) } - const { bytesConsumed, item } = yield* decodeItemWithLength(data) + const { bytesConsumed, item } = yield* decodeItemWithLength(data, options) // Verify that all input bytes were consumed if (bytesConsumed !== data.length) { @@ -351,8 +433,8 @@ const internalDecode = (data: Uint8Array): Effect.Effect => // Internal encoding functions -const encodeUint = (value: bigint, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeUint = (value: bigint, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { if (value < 0n) { return yield* new CBORError({ message: `Cannot encode negative value ${value} as unsigned integer` @@ -401,8 +483,8 @@ const encodeUint = (value: bigint, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeNint = (value: bigint, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { if (value >= 0n) { return yield* new CBORError({ message: `Cannot encode non-negative value ${value} as negative integer` @@ -454,8 +536,8 @@ const encodeNint = (value: bigint, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeBytes = (value: Uint8Array, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { const length = value.length let headerBytes: Uint8Array const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) @@ -486,8 +568,8 @@ const encodeBytes = (value: Uint8Array, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeText = (value: string, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { const utf8Bytes = new TextEncoder().encode(value) const length = utf8Bytes.length let headerBytes: Uint8Array @@ -519,8 +601,8 @@ const encodeText = (value: string, options: CodecOptions): Effect.Effect, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeArray = (value: ReadonlyArray, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { const length = value.length const chunks: Array = [] const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) @@ -575,8 +657,8 @@ const encodeArray = (value: ReadonlyArray, options: CodecOptions): Effect. return result }) -const encodeMap = (value: ReadonlyMap, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeMap = (value: ReadonlyMap, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { // Convert Map to array of pairs for processing const pairs = Array.from(value.entries()) const length = pairs.length @@ -590,9 +672,9 @@ const encodeMap = (value: ReadonlyMap, options: CodecOptions): Effec if (sortKeys) { // Sort by encoded key length only (matches old CBOR.ts behavior) - const tempEncodedPairs = yield* Effect.all( + const tempEncodedPairs = yield* Eff.all( pairs.map(([key, val]) => - Effect.gen(function* () { + Eff.gen(function* () { const encodedKey = yield* internalEncode(key, options) const encodedValue = yield* internalEncode(val, options) return { encodedKey, encodedValue } @@ -679,115 +761,49 @@ const encodeMap = (value: ReadonlyMap, options: CodecOptions): Effec return result }) +/** + * Encode a Record (JavaScript object) as a CBOR Map. + * + * **Number Key Support:** Number keys (e.g., `42`) are automatically converted to + * bigint and encoded as CBOR integers, not text strings. + * + * **Key Ordering:** Records follow JavaScript object property enumeration: + * - Integer-like strings in ascending numeric order + * - Other strings in insertion order + * This may differ from Map insertion order with mixed key types. + * + * @param value - The record to encode + * @param options - CBOR encoding options + * @returns Effect that yields the encoded CBOR bytes + * + * @since 1.0.0 + * @category encoding + */ const encodeRecord = ( - value: { readonly [key: string]: CBOR }, + value: { readonly [key: string | number]: CBOR }, options: CodecOptions -): Effect.Effect => - Effect.gen(function* () { - // Convert Record to array of pairs for processing - const pairs = Object.entries(value) - const length = pairs.length - const chunks: Array = [] - const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) - const sortKeys = options.mode === "canonical" || (options.mode === "custom" && options.sortMapKeys) - const useIndefinite = options.mode === "custom" && options.useIndefiniteMaps && length > 0 - - // Sort keys if required (canonical CBOR requires sorted keys) - let encodedPairs: Array<{ encodedKey: Uint8Array; encodedValue: Uint8Array }> | undefined - - if (sortKeys) { - // Sort by encoded key length only (matches old CBOR.ts behavior) - const tempEncodedPairs = yield* Effect.all( - pairs.map(([key, val]) => - Effect.gen(function* () { - const encodedKey = yield* internalEncode(key, options) - const encodedValue = yield* internalEncode(val, options) - return { encodedKey, encodedValue } - }) - ) - ) - - // Sort by encoded key length only (not full lexicographic order) - tempEncodedPairs.sort((a, b) => { - return a.encodedKey.length - b.encodedKey.length - }) - - encodedPairs = tempEncodedPairs - } - - if (useIndefinite) { - // Indefinite-length map - chunks.push(new Uint8Array([0xbf])) // Start indefinite map - - // Encode each key-value pair - if (encodedPairs) { - // Use pre-encoded pairs for sorted output - for (const { encodedKey, encodedValue } of encodedPairs) { - chunks.push(encodedKey) - chunks.push(encodedValue) - } - } else { - // Encode pairs on-the-fly for unsorted output - for (const [key, val] of pairs) { - const encodedKey = yield* internalEncode(key, options) - const encodedValue = yield* internalEncode(val, options) - chunks.push(encodedKey) - chunks.push(encodedValue) - } - } - - // Add break marker - chunks.push(new Uint8Array([0xff])) - } else { - // Definite-length map - if (length < 24) { - chunks.push(new Uint8Array([0xa0 + length])) - } else if (length < 256 && useMinimal) { - chunks.push(new Uint8Array([0xb8, length])) - } else if (length < 65536 && useMinimal) { - chunks.push(new Uint8Array([0xb9, length >> 8, length & 0xff])) - } else if (length < 4294967296 && useMinimal) { - chunks.push( - new Uint8Array([0xba, (length >> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff]) - ) - } else { - return yield* new CBORError({ - message: `Record too long: ${length} entries` - }) - } - - // Encode each key-value pair - if (encodedPairs) { - // Use pre-encoded pairs for sorted output - for (const { encodedKey, encodedValue } of encodedPairs) { - chunks.push(encodedKey) - chunks.push(encodedValue) - } - } else { - // Encode pairs on-the-fly for unsorted output - for (const [key, val] of pairs) { - const encodedKey = yield* internalEncode(key, options) - const encodedValue = yield* internalEncode(val, options) - chunks.push(encodedKey) - chunks.push(encodedValue) - } +): Eff.Effect => + Eff.gen(function* () { + // Convert Record to Map to preserve insertion order and reuse Map encoding logic + // Handle the case where number keys get converted to strings by Object.entries + const rawPairs = Object.entries(value) + const mapEntries = rawPairs.map(([key, val]) => { + // Check if the string key represents a number that should be encoded as bigint + const numKey = Number(key) + if (Number.isInteger(numKey) && !Number.isNaN(numKey) && key === String(numKey)) { + // Convert back to bigint for proper CBOR encoding + return [BigInt(numKey), val] as [CBOR, CBOR] } - } - - // Combine chunks - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0) - const result = new Uint8Array(totalLength) - let offset = 0 - for (const chunk of chunks) { - result.set(chunk, offset) - offset += chunk.length - } + return [key, val] as [CBOR, CBOR] + }) - return result + // Create a Map from the processed entries and encode it + const map = new Map(mapEntries) + return yield* encodeMap(map, options) }) -const encodeTag = (tag: number, value: CBOR, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const encodeTag = (tag: number, value: CBOR, options: CodecOptions): Eff.Effect => + Eff.gen(function* () { const chunks: Array = [] const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) @@ -818,8 +834,8 @@ const encodeTag = (tag: number, value: CBOR, options: CodecOptions): Effect.Effe return result }) -const encodeSimple = (value: boolean | null | undefined): Effect.Effect => - Effect.gen(function* () { +const encodeSimple = (value: boolean | null | undefined): Eff.Effect => + Eff.gen(function* () { if (value === false) return new Uint8Array([0xf4]) if (value === true) return new Uint8Array([0xf5]) if (value === null) return new Uint8Array([0xf6]) @@ -830,8 +846,8 @@ const encodeSimple = (value: boolean | null | undefined): Effect.Effect => - Effect.succeed( +const encodeFloat = (value: number, options: CodecOptions): Eff.Effect => + Eff.succeed( (() => { if (Number.isNaN(value)) { return new Uint8Array([0xf9, 0x7e, 0x00]) // Half-precision NaN @@ -868,8 +884,8 @@ const encodeFloat = (value: number, options: CodecOptions): Effect.Effect => - Effect.gen(function* () { +const decodeUint = (data: Uint8Array): Eff.Effect => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f @@ -914,8 +930,8 @@ const decodeUint = (data: Uint8Array): Effect.Effect => } }) -const decodeNint = (data: Uint8Array): Effect.Effect => - Effect.gen(function* () { +const decodeNint = (data: Uint8Array): Eff.Effect => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f @@ -960,8 +976,8 @@ const decodeNint = (data: Uint8Array): Effect.Effect => } }) -const decodeBytesWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => - Effect.gen(function* () { +const decodeBytesWithLength = (data: Uint8Array): Eff.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f @@ -1032,8 +1048,8 @@ const decodeBytesWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; by } }) -const decodeTextWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => - Effect.gen(function* () { +const decodeTextWithLength = (data: Uint8Array): Eff.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f @@ -1115,8 +1131,11 @@ const decodeTextWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byt }) // Helper function to decode an item and return both the item and bytes consumed -const decodeItemWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => - Effect.gen(function* () { +const decodeItemWithLength = ( + data: Uint8Array, + options: CodecOptions = CML_DEFAULT_OPTIONS +): Eff.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => + Eff.gen(function* () { if (data.length === 0) { return yield* new CBORError({ message: "Empty CBOR data" }) } @@ -1180,19 +1199,19 @@ const decodeItemWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byt break } case CBOR_MAJOR_TYPE.ARRAY: { - const { bytesConsumed: arrayBytes, item: arrayItem } = yield* decodeArrayWithLength(data) + const { bytesConsumed: arrayBytes, item: arrayItem } = yield* decodeArrayWithLength(data, options) item = arrayItem bytesConsumed = arrayBytes break } case CBOR_MAJOR_TYPE.MAP: { - const { bytesConsumed: mapBytes, item: mapItem } = yield* decodeMapWithLength(data) + const { bytesConsumed: mapBytes, item: mapItem } = yield* decodeMapWithLength(data, options) item = mapItem bytesConsumed = mapBytes break } case CBOR_MAJOR_TYPE.TAG: { - const { bytesConsumed: tagBytes, item: tagItem } = yield* decodeTagWithLength(data) + const { bytesConsumed: tagBytes, item: tagItem } = yield* decodeTagWithLength(data, options) item = tagItem bytesConsumed = tagBytes break @@ -1225,8 +1244,11 @@ const decodeItemWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byt return { item, bytesConsumed } }) -const decodeArrayWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => - Effect.gen(function* () { +const decodeArrayWithLength = ( + data: Uint8Array, + options: CodecOptions = CML_DEFAULT_OPTIONS +): Eff.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f @@ -1243,7 +1265,7 @@ const decodeArrayWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; by break } - const { bytesConsumed, item } = yield* decodeItemWithLength(data.slice(offset)) + const { bytesConsumed, item } = yield* decodeItemWithLength(data.slice(offset), options) result.push(item) offset += bytesConsumed } @@ -1271,7 +1293,7 @@ const decodeArrayWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; by }) } - const { bytesConsumed, item } = yield* decodeItemWithLength(data.slice(offset)) + const { bytesConsumed, item } = yield* decodeItemWithLength(data.slice(offset), options) result.push(item) offset += bytesConsumed } @@ -1283,15 +1305,37 @@ const decodeArrayWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; by } }) -const decodeMapWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => - Effect.gen(function* () { +/** + * Helper function to convert Map entries to a plain object. + * Used when mapsAsObjects option is enabled. + */ +const convertEntriesToObject = (entries: Array<[CBOR, CBOR]>): Record => { + const obj: Record = {} + for (const [key, value] of entries) { + if (typeof key === "string" || typeof key === "number") { + obj[key] = value + } else if (typeof key === "bigint") { + obj[Number(key)] = value + } else { + // For non-primitive keys, convert to string + obj[String(key)] = value + } + } + return obj +} + +const decodeMapWithLength = ( + data: Uint8Array, + options: CodecOptions = CML_DEFAULT_OPTIONS +): Eff.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f if (additionalInfo === CBOR_ADDITIONAL_INFO.INDEFINITE) { // Indefinite-length map let offset = 1 - const result = new Map() + const entries: Array<[CBOR, CBOR]> = [] let foundBreak = false while (offset < data.length) { @@ -1302,7 +1346,7 @@ const decodeMapWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte } // Decode key - const { bytesConsumed: keyBytes, item: key } = yield* decodeItemWithLength(data.slice(offset)) + const { bytesConsumed: keyBytes, item: key } = yield* decodeItemWithLength(data.slice(offset), options) offset += keyBytes // Decode value @@ -1312,10 +1356,10 @@ const decodeMapWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte }) } - const { bytesConsumed: valueBytes, item: value } = yield* decodeItemWithLength(data.slice(offset)) + const { bytesConsumed: valueBytes, item: value } = yield* decodeItemWithLength(data.slice(offset), options) offset += valueBytes - result.set(key, value) + entries.push([key, value]) } if (!foundBreak) { @@ -1324,12 +1368,15 @@ const decodeMapWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte }) } + // Convert to Map or Object based on option + const result = options.mapsAsObjects ? convertEntriesToObject(entries) : new Map(entries) + return { item: result, bytesConsumed: offset } } else { // Definite-length map const { bytesRead, length } = yield* decodeLength(data, 0) let offset = bytesRead - const result = new Map() + const entries: Array<[CBOR, CBOR]> = [] for (let i = 0; i < length; i++) { // Decode key @@ -1339,7 +1386,7 @@ const decodeMapWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte }) } - const { bytesConsumed: keyBytes, item: key } = yield* decodeItemWithLength(data.slice(offset)) + const { bytesConsumed: keyBytes, item: key } = yield* decodeItemWithLength(data.slice(offset), options) offset += keyBytes // Decode value @@ -1349,18 +1396,24 @@ const decodeMapWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte }) } - const { bytesConsumed: valueBytes, item: value } = yield* decodeItemWithLength(data.slice(offset)) + const { bytesConsumed: valueBytes, item: value } = yield* decodeItemWithLength(data.slice(offset), options) offset += valueBytes - result.set(key, value) + entries.push([key, value]) } + // Convert to Map or Object based on option + const result = options.mapsAsObjects ? convertEntriesToObject(entries) : new Map(entries) + return { item: result, bytesConsumed: offset } } }) -const decodeTagWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => - Effect.gen(function* () { +const decodeTagWithLength = ( + data: Uint8Array, + options: CodecOptions = DEFAULT_OPTIONS +): Eff.Effect<{ item: CBOR; bytesConsumed: number }, CBORError> => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f let tagValue: number @@ -1391,7 +1444,7 @@ const decodeTagWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte }) } - const { bytesConsumed, item: innerValue } = yield* decodeItemWithLength(data.slice(dataOffset)) + const { bytesConsumed, item: innerValue } = yield* decodeItemWithLength(data.slice(dataOffset), options) // Handle special tags that should be converted to plain values if (tagValue === 2) { @@ -1423,13 +1476,16 @@ const decodeTagWithLength = (data: Uint8Array): Effect.Effect<{ item: CBOR; byte // For all other tags, return as tagged object return { - item: new Tag({ tag: tagValue, value: innerValue }), + item: Tag.make({ + tag: tagValue, + value: innerValue + }), bytesConsumed: dataOffset + bytesConsumed } }) -const decodeSimpleOrFloat = (data: Uint8Array): Effect.Effect => - Effect.gen(function* () { +const decodeSimpleOrFloat = (data: Uint8Array): Eff.Effect => + Eff.gen(function* () { const firstByte = data[0] const additionalInfo = firstByte & 0x1f @@ -1532,11 +1588,8 @@ const bytesToBigint = (bytes: Uint8Array): bigint => { // Helper function for length decoding -const decodeLength = ( - data: Uint8Array, - offset: number -): Effect.Effect<{ length: number; bytesRead: number }, CBORError> => - Effect.gen(function* () { +const decodeLength = (data: Uint8Array, offset: number): Eff.Effect<{ length: number; bytesRead: number }, CBORError> => + Eff.gen(function* () { if (offset >= data.length) { return yield* new CBORError({ message: "Insufficient data for length decoding" @@ -1634,8 +1687,8 @@ export const FromBytes = (options: CodecOptions) => Schema.transformOrFail(Schema.Uint8ArrayFromSelf, CBORValueSchema, { strict: true, decode: (fromA, _, ast) => - Effect.mapError( - internalDecode(fromA), + Eff.mapError( + internalDecode(fromA, options), (error) => new ParseResult.Type( ast, @@ -1644,7 +1697,7 @@ export const FromBytes = (options: CodecOptions) => ) ), encode: (toA, _, ast) => - Effect.mapError( + Eff.mapError( internalEncode(toA, options), (error) => new ParseResult.Type( @@ -1657,11 +1710,116 @@ export const FromBytes = (options: CodecOptions) => export const FromHex = (options: CodecOptions) => Schema.compose(Bytes.FromHex, FromBytes(options)) -export const Codec = (options: CodecOptions = DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - CBORError - ) +export namespace Effect { + /** + * Decode CBOR bytes to a CBOR value using Effect error handling. + * + * @since 1.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options: CodecOptions = CML_DEFAULT_OPTIONS + ): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes(options))(bytes), + (cause) => + new CBORError({ + message: "Failed to parse CBOR from bytes", + cause + }) + ) + + /** + * Decode CBOR hex string to a CBOR value using Effect error handling. + * + * @since 1.0.0 + * @category parsing + */ + export const fromCBORHex = (hex: string, options: CodecOptions = CML_DEFAULT_OPTIONS): Eff.Effect => + Eff.mapError( + Schema.decode(FromHex(options))(hex), + (cause) => + new CBORError({ + message: "Failed to parse CBOR from hex", + cause + }) + ) + + /** + * Encode a CBOR value to bytes using Effect error handling. + * + * @since 1.0.0 + * @category encoding + */ + export const toCBORBytes = ( + value: CBOR, + options: CodecOptions = CML_DEFAULT_OPTIONS + ): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes(options))(value), + (cause) => + new CBORError({ + message: "Failed to encode CBOR to bytes", + cause + }) + ) + + /** + * Encode a CBOR value to hex string using Effect error handling. + * + * @since 1.0.0 + * @category encoding + */ + export const toCBORHex = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPTIONS): Eff.Effect => + Eff.mapError( + Schema.encode(FromHex(options))(value), + (cause) => + new CBORError({ + message: "Failed to encode CBOR to hex", + cause + }) + ) +} + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse a CBOR value from CBOR bytes. + * + * @since 1.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CodecOptions): CBOR => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse a CBOR value from CBOR hex string. + * + * @since 1.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CodecOptions): CBOR => Eff.runSync(Effect.fromCBORHex(hex, options)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a CBOR value to CBOR bytes. + * + * @since 1.0.0 + * @category encoding + */ +export const toCBORBytes = (value: CBOR, options?: CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(value, options)) + +/** + * Convert a CBOR value to CBOR hex string. + * + * @since 1.0.0 + * @category encoding + */ +export const toCBORHex = (value: CBOR, options?: CodecOptions): string => Eff.runSync(Effect.toCBORHex(value, options)) diff --git a/packages/evolution/src/Certificate.ts b/packages/evolution/src/Certificate.ts index 78d69f94..150a79db 100644 --- a/packages/evolution/src/Certificate.ts +++ b/packages/evolution/src/Certificate.ts @@ -1,12 +1,14 @@ -import { Data, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" -// import * as PoolParams from "./PoolParams.js"; // Temporarily disabled import * as Anchor from "./Anchor.js" +import * as Bytes from "./Bytes.js" +import * as CBOR from "./CBOR.js" import * as Coin from "./Coin.js" import * as Credential from "./Credential.js" import * as DRep from "./DRep.js" import * as EpochNo from "./EpochNo.js" import * as PoolKeyHash from "./PoolKeyHash.js" +import * as PoolParams from "./PoolParams.js" /** * Error class for Certificate related operations. @@ -16,9 +18,112 @@ import * as PoolKeyHash from "./PoolKeyHash.js" */ export class CertificateError extends Data.TaggedError("CertificateError")<{ message?: string - reason?: "InvalidType" | "UnsupportedCertificate" + cause?: unknown }> {} +export class StakeRegistration extends Schema.TaggedClass("StakeRegistration")("StakeRegistration", { + stakeCredential: Credential.Credential +}) {} + +export class StakeDeregistration extends Schema.TaggedClass("StakeDeregistration")( + "StakeDeregistration", + { + stakeCredential: Credential.Credential + } +) {} + +export class StakeDelegation extends Schema.TaggedClass("StakeDelegation")("StakeDelegation", { + stakeCredential: Credential.Credential, + poolKeyHash: PoolKeyHash.PoolKeyHash +}) {} + +export class PoolRegistration extends Schema.TaggedClass("PoolRegistration")("PoolRegistration", { + poolParams: PoolParams.PoolParams +}) {} + +export class PoolRetirement extends Schema.TaggedClass("PoolRetirement")("PoolRetirement", { + poolKeyHash: PoolKeyHash.PoolKeyHash, + epoch: EpochNo.EpochNoSchema +}) {} + +export class RegCert extends Schema.TaggedClass("RegCert")("RegCert", { + stakeCredential: Credential.Credential, + coin: Coin.Coin +}) {} + +export class UnregCert extends Schema.TaggedClass("UnregCert")("UnregCert", { + stakeCredential: Credential.Credential, + coin: Coin.Coin +}) {} + +export class VoteDelegCert extends Schema.TaggedClass("VoteDelegCert")("VoteDelegCert", { + stakeCredential: Credential.Credential, + drep: DRep.DRep +}) {} + +export class StakeVoteDelegCert extends Schema.TaggedClass("StakeVoteDelegCert")( + "StakeVoteDelegCert", + { + stakeCredential: Credential.Credential, + poolKeyHash: PoolKeyHash.PoolKeyHash, + drep: DRep.DRep + } +) {} + +export class StakeRegDelegCert extends Schema.TaggedClass("StakeRegDelegCert")("StakeRegDelegCert", { + stakeCredential: Credential.Credential, + poolKeyHash: PoolKeyHash.PoolKeyHash, + coin: Coin.Coin +}) {} + +export class VoteRegDelegCert extends Schema.TaggedClass("VoteRegDelegCert")("VoteRegDelegCert", { + stakeCredential: Credential.Credential, + drep: DRep.DRep, + coin: Coin.Coin +}) {} + +export class StakeVoteRegDelegCert extends Schema.TaggedClass("StakeVoteRegDelegCert")( + "StakeVoteRegDelegCert", + { + stakeCredential: Credential.Credential, + poolKeyHash: PoolKeyHash.PoolKeyHash, + drep: DRep.DRep, + coin: Coin.Coin + } +) {} + +export class AuthCommitteeHotCert extends Schema.TaggedClass("AuthCommitteeHotCert")( + "AuthCommitteeHotCert", + { + committeeColdCredential: Credential.Credential, + committeeHotCredential: Credential.Credential + } +) {} + +export class ResignCommitteeColdCert extends Schema.TaggedClass("ResignCommitteeColdCert")( + "ResignCommitteeColdCert", + { + committeeColdCredential: Credential.Credential, + anchor: Schema.NullishOr(Anchor.Anchor) + } +) {} + +export class RegDrepCert extends Schema.TaggedClass("RegDrepCert")("RegDrepCert", { + drepCredential: Credential.Credential, + coin: Coin.Coin, + anchor: Schema.NullishOr(Anchor.Anchor) +}) {} + +export class UnregDrepCert extends Schema.TaggedClass("UnregDrepCert")("UnregDrepCert", { + drepCredential: Credential.Credential, + coin: Coin.Coin +}) {} + +export class UpdateDrepCert extends Schema.TaggedClass("UpdateDrepCert")("UpdateDrepCert", { + drepCredential: Credential.Credential, + anchor: Schema.NullishOr(Anchor.Anchor) +}) {} + /** * Certificate union schema based on Conway CDDL specification * @@ -66,95 +171,362 @@ export class CertificateError extends Data.TaggedError("CertificateError")<{ */ export const Certificate = Schema.Union( // 0: stake_registration = (0, stake_credential) - Schema.TaggedStruct("StakeRegistration", { - stakeCredential: Credential.Credential - }), + StakeRegistration, // 1: stake_deregistration = (1, stake_credential) - Schema.TaggedStruct("StakeDeregistration", { - stakeCredential: Credential.Credential - }), + StakeDeregistration, // 2: stake_delegation = (2, stake_credential, pool_keyhash) - Schema.TaggedStruct("StakeDelegation", { - stakeCredential: Credential.Credential, - poolKeyHash: PoolKeyHash.PoolKeyHash - }), - // 3: pool_registration = (3, pool_params) - Temporarily disabled - // Schema.TaggedStruct("PoolRegistration", { - // poolParams: PoolParams.PoolParams, - // }), + StakeDelegation, + // 3: pool_registration = (3, pool_params) + PoolRegistration, // 4: pool_retirement = (4, pool_keyhash, epoch_no) - Schema.TaggedStruct("PoolRetirement", { - poolKeyHash: PoolKeyHash.PoolKeyHash, - epoch: EpochNo.EpochNoSchema - }), + PoolRetirement, // 7: reg_cert = (7, stake_credential, coin) - Schema.TaggedStruct("RegCert", { - stakeCredential: Credential.Credential, - coin: Coin.CoinSchema - }), + RegCert, // 8: unreg_cert = (8, stake_credential, coin) - Schema.TaggedStruct("UnregCert", { - stakeCredential: Credential.Credential, - coin: Coin.CoinSchema - }), + UnregCert, // 9: vote_deleg_cert = (9, stake_credential, drep) - Schema.TaggedStruct("VoteDelegCert", { - stakeCredential: Credential.Credential, - drep: DRep.DRep - }), + VoteDelegCert, // 10: stake_vote_deleg_cert = (10, stake_credential, pool_keyhash, drep) - Schema.TaggedStruct("StakeVoteDelegCert", { - stakeCredential: Credential.Credential, - poolKeyHash: PoolKeyHash.PoolKeyHash, - drep: DRep.DRep - }), + StakeVoteDelegCert, // 11: stake_reg_deleg_cert = (11, stake_credential, pool_keyhash, coin) - Schema.TaggedStruct("StakeRegDelegCert", { - stakeCredential: Credential.Credential, - poolKeyHash: PoolKeyHash.PoolKeyHash, - coin: Coin.CoinSchema - }), + StakeRegDelegCert, // 12: vote_reg_deleg_cert = (12, stake_credential, drep, coin) - Schema.TaggedStruct("VoteRegDelegCert", { - stakeCredential: Credential.Credential, - drep: DRep.DRep, - coin: Coin.CoinSchema - }), + VoteRegDelegCert, // 13: stake_vote_reg_deleg_cert = (13, stake_credential, pool_keyhash, drep, coin) - Schema.TaggedStruct("StakeVoteRegDelegCert", { - stakeCredential: Credential.Credential, - poolKeyHash: PoolKeyHash.PoolKeyHash, - drep: DRep.DRep, - coin: Coin.CoinSchema - }), + StakeVoteRegDelegCert, // 14: auth_committee_hot_cert = (14, committee_cold_credential, committee_hot_credential) - Schema.TaggedStruct("AuthCommitteeHotCert", { - committeeColdCredential: Credential.Credential, - committeeHotCredential: Credential.Credential - }), + AuthCommitteeHotCert, // 15: resign_committee_cold_cert = (15, committee_cold_credential, anchor/ nil) - Schema.TaggedStruct("ResignCommitteeColdCert", { - committeeColdCredential: Credential.Credential, - anchor: Schema.NullishOr(Anchor.Anchor) - }), + ResignCommitteeColdCert, // 16: reg_drep_cert = (16, drep_credential, coin, anchor/ nil) - Schema.TaggedStruct("RegDrepCert", { - drepCredential: Credential.Credential, - coin: Coin.CoinSchema, - anchor: Schema.NullishOr(Anchor.Anchor) - }), + RegDrepCert, // 17: unreg_drep_cert = (17, drep_credential, coin) - Schema.TaggedStruct("UnregDrepCert", { - drepCredential: Credential.Credential, - coin: Coin.CoinSchema - }), + UnregDrepCert, // 18: update_drep_cert = (18, drep_credential, anchor/ nil) - Schema.TaggedStruct("UpdateDrepCert", { - drepCredential: Credential.Credential, - anchor: Schema.NullishOr(Anchor.Anchor) - }) + UpdateDrepCert +) + +export const CDDLSchema = Schema.Union( + // 0: stake_registration = (0, stake_credential) + Schema.Tuple(Schema.Literal(0n), Credential.CDDLSchema), + // 1: stake_deregistration = (1, stake_credential) + Schema.Tuple(Schema.Literal(1n), Credential.CDDLSchema), + // 2: stake_delegation = (2, stake_credential, pool_keyhash) + Schema.Tuple(Schema.Literal(2n), Credential.CDDLSchema, CBOR.ByteArray), + // 3: pool_registration = (3, pool_params) + Schema.Tuple(Schema.Literal(3n), PoolParams.CDDLSchema), + // 4: pool_retirement = (4, pool_keyhash, epoch_no) + Schema.Tuple(Schema.Literal(4n), CBOR.ByteArray, CBOR.Integer), + // 7: reg_cert = (7, stake_credential , coin) + Schema.Tuple(Schema.Literal(7n), Credential.CDDLSchema, CBOR.Integer), + // 8: unreg_cert = (8, stake_credential, coin) + Schema.Tuple(Schema.Literal(8n), Credential.CDDLSchema, CBOR.Integer), + // 9: vote_deleg_cert = (9, stake_credential, drep) + Schema.Tuple(Schema.Literal(9n), Credential.CDDLSchema, DRep.CDDLSchema), + // 10: stake_vote_deleg_cert = (10, stake_credential, pool_keyhash, drep) + Schema.Tuple(Schema.Literal(10n), Credential.CDDLSchema, CBOR.ByteArray, DRep.CDDLSchema), + // 11: stake_reg_deleg_cert = (11, stake_credential, pool_keyhash, coin) + Schema.Tuple(Schema.Literal(11n), Credential.CDDLSchema, CBOR.ByteArray, CBOR.Integer), + // 12: vote_reg_deleg_cert = (12, stake_credential, drep, coin) + Schema.Tuple(Schema.Literal(12n), Credential.CDDLSchema, DRep.CDDLSchema, CBOR.Integer), + // 13: stake_vote_reg_deleg_cert = (13, stake_credential, pool_keyhash, drep, coin) + Schema.Tuple(Schema.Literal(13n), Credential.CDDLSchema, CBOR.ByteArray, DRep.CDDLSchema, CBOR.Integer), + // 14: auth_committee_hot_cert = (14, committee_cold_credential, committee_hot_credential) + Schema.Tuple(Schema.Literal(14n), Credential.CDDLSchema, Credential.CDDLSchema), + // 15: resign_committee_cold_cert = (15, committee_cold_credential, anchor/ nil) + Schema.Tuple(Schema.Literal(15n), Credential.CDDLSchema, Schema.NullishOr(Anchor.CDDLSchema)), + // 16: reg_drep_cert = (16, drep_credential, coin, anchor/ nil) + Schema.Tuple(Schema.Literal(16n), Credential.CDDLSchema, CBOR.Integer, Schema.NullishOr(Anchor.CDDLSchema)), + // 17: unreg_drep_cert = (17, drep_credential, coin) + Schema.Tuple(Schema.Literal(17n), Credential.CDDLSchema, CBOR.Integer), + // 18: update_drep_cert = (18, drep_credential, anchor/ nil) + Schema.Tuple(Schema.Literal(18n), Credential.CDDLSchema, Schema.NullishOr(Anchor.CDDLSchema)) ) +/** + * CDDL schema for Certificate based on Conway specification. + * + * Transforms between CBOR tuple representation and Certificate union. + * Each certificate type is encoded as [type_id, ...fields] + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Certificate), { + strict: true, + encode: (toA) => + Eff.gen(function* () { + switch (toA._tag) { + case "StakeRegistration": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + return [0n, credentialCDDL] as const + } + case "StakeDeregistration": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + return [1n, credentialCDDL] as const + } + case "StakeDelegation": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + const poolKeyHashBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.poolKeyHash) + return [2n, credentialCDDL, poolKeyHashBytes] as const + } + case "PoolRegistration": { + const poolParamsCDDL = yield* ParseResult.encode(PoolParams.FromCDDL)(toA.poolParams) + return [3n, poolParamsCDDL] as const + } + case "PoolRetirement": { + const poolKeyHashBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.poolKeyHash) + return [4n, poolKeyHashBytes, BigInt(toA.epoch)] as const + } + case "RegCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + return [7n, credentialCDDL, toA.coin] as const + } + case "UnregCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + return [8n, credentialCDDL, toA.coin] as const + } + case "VoteDelegCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + const drepCDDL = yield* ParseResult.encode(DRep.FromCDDL)(toA.drep) + return [9n, credentialCDDL, drepCDDL] as const + } + case "StakeVoteDelegCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + const poolKeyHashBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.poolKeyHash) + const drepCDDL = yield* ParseResult.encode(DRep.FromCDDL)(toA.drep) + return [10n, credentialCDDL, poolKeyHashBytes, drepCDDL] as const + } + case "StakeRegDelegCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + const poolKeyHashBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.poolKeyHash) + return [11n, credentialCDDL, poolKeyHashBytes, toA.coin] as const + } + case "VoteRegDelegCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + const drepCDDL = yield* ParseResult.encode(DRep.FromCDDL)(toA.drep) + return [12n, credentialCDDL, drepCDDL, toA.coin] as const + } + case "StakeVoteRegDelegCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.stakeCredential) + const poolKeyHashBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.poolKeyHash) + const drepCDDL = yield* ParseResult.encode(DRep.FromCDDL)(toA.drep) + return [13n, credentialCDDL, poolKeyHashBytes, drepCDDL, toA.coin] as const + } + case "AuthCommitteeHotCert": { + const coldCredentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.committeeColdCredential) + const hotCredentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.committeeHotCredential) + return [14n, coldCredentialCDDL, hotCredentialCDDL] as const + } + case "ResignCommitteeColdCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.committeeColdCredential) + const anchorCDDL = toA.anchor ? yield* ParseResult.encode(Anchor.FromCDDL)(toA.anchor) : null + return [15n, credentialCDDL, anchorCDDL] as const + } + case "RegDrepCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.drepCredential) + const anchorCDDL = toA.anchor ? yield* ParseResult.encode(Anchor.FromCDDL)(toA.anchor) : null + return [16n, credentialCDDL, toA.coin, anchorCDDL] as const + } + case "UnregDrepCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.drepCredential) + return [17n, credentialCDDL, toA.coin] as const + } + case "UpdateDrepCert": { + const credentialCDDL = yield* ParseResult.encode(Credential.FromCDDL)(toA.drepCredential) + const anchorCDDL = toA.anchor ? yield* ParseResult.encode(Anchor.FromCDDL)(toA.anchor) : null + return [18n, credentialCDDL, anchorCDDL] as const + } + // default: + // return yield* ParseResult.fail( + // new ParseResult.Type(CDDLSchema.ast, toA, `Unsupported certificate type: ${(toA as any)._tag}`) + // ) + } + }), + decode: (fromA) => + Eff.gen(function* () { + // const [typeId, ...fields] = fromA + + switch (fromA[0]) { + case 0n: { + // stake_registration = (0, stake_credential) + // const [credentialCDDL] = fields + const [, credentialCDDL] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + return yield* ParseResult.decode(Certificate)({ _tag: "StakeRegistration", stakeCredential }) + } + case 1n: { + // stake_deregistration = (1, stake_credential) + const [, credentialCDDL] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + return yield* ParseResult.decode(Certificate)({ _tag: "StakeDeregistration", stakeCredential }) + } + case 2n: { + // stake_delegation = (2, stake_credential, pool_keyhash) + const [, credentialCDDL, poolKeyHashBytes] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const poolKeyHash = yield* ParseResult.decode(PoolKeyHash.FromBytes)(poolKeyHashBytes) + return yield* ParseResult.decode(Certificate)({ _tag: "StakeDelegation", stakeCredential, poolKeyHash }) + } + case 3n: { + // pool_registration = (3, pool_params) + const [, poolParamsCDDL] = fromA + const poolParams = yield* ParseResult.decode(PoolParams.FromCDDL)(poolParamsCDDL) + return { _tag: "PoolRegistration", poolParams } as const + } + case 4n: { + // pool_retirement = (4, pool_keyhash, epoch_no) + const [, poolKeyHashBytes, epochBigInt] = fromA + const poolKeyHash = yield* ParseResult.decode(PoolKeyHash.FromBytes)(poolKeyHashBytes) + const epoch = EpochNo.make(epochBigInt) + return yield* ParseResult.decode(Certificate)({ _tag: "PoolRetirement", poolKeyHash, epoch }) + } + case 7n: { + // reg_cert = (7, stake_credential, coin) + const [, credentialCDDL, coinBigInt] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const coin = Coin.make(coinBigInt) + return yield* ParseResult.decode(Certificate)({ _tag: "RegCert", stakeCredential, coin }) + } + case 8n: { + // unreg_cert = (8, stake_credential, coin) + const [, credentialCDDL, coinBigInt] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const coin = Coin.make(coinBigInt) + return yield* ParseResult.decode(Certificate)({ _tag: "UnregCert", stakeCredential, coin }) + } + case 9n: { + // vote_deleg_cert = (9, stake_credential, drep) + const [, credentialCDDL, drepCDDL] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const drep = yield* ParseResult.decode(DRep.FromCDDL)(drepCDDL) + return yield* ParseResult.decode(Certificate)({ _tag: "VoteDelegCert", stakeCredential, drep }) + } + case 10n: { + // stake_vote_deleg_cert = (10, stake_credential, pool_keyhash, drep) + const [, credentialCDDL, poolKeyHashBytes, drepCDDL] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const poolKeyHash = yield* ParseResult.decode(PoolKeyHash.FromBytes)(poolKeyHashBytes) + const drep = yield* ParseResult.decode(DRep.FromCDDL)(drepCDDL) + return yield* ParseResult.decode(Certificate)({ + _tag: "StakeVoteDelegCert", + stakeCredential, + poolKeyHash, + drep + }) + } + case 11n: { + // stake_reg_deleg_cert = (11, stake_credential, pool_keyhash, coin) + const [, credentialCDDL, poolKeyHashBytes, coinBigInt] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const poolKeyHash = yield* ParseResult.decode(PoolKeyHash.FromBytes)(poolKeyHashBytes) + const coin = Coin.make(coinBigInt) + return yield* ParseResult.decode(Certificate)({ + _tag: "StakeRegDelegCert", + stakeCredential, + poolKeyHash, + coin + }) + } + case 12n: { + // vote_reg_deleg_cert = (12, stake_credential, drep, coin) + const [, credentialCDDL, drepCDDL, coinBigInt] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const drep = yield* ParseResult.decode(DRep.FromCDDL)(drepCDDL) + const coin = Coin.make(coinBigInt) + return yield* ParseResult.decode(Certificate)({ _tag: "VoteRegDelegCert", stakeCredential, drep, coin }) + } + case 13n: { + // stake_vote_reg_deleg_cert = (13, stake_credential, pool_keyhash, drep, coin) + const [, credentialCDDL, poolKeyHashBytes, drepCDDL, coinBigInt] = fromA + const stakeCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const poolKeyHash = yield* ParseResult.decode(PoolKeyHash.FromBytes)(poolKeyHashBytes) + const drep = yield* ParseResult.decode(DRep.FromCDDL)(drepCDDL) + const coin = Coin.make(coinBigInt) + return yield* ParseResult.decode(Certificate)({ + _tag: "StakeVoteRegDelegCert", + stakeCredential, + poolKeyHash, + drep, + coin + }) + } + case 14n: { + // auth_committee_hot_cert = (14, committee_cold_credential, committee_hot_credential) + const [, coldCredentialCDDL, hotCredentialCDDL] = fromA + const committeeColdCredential = yield* ParseResult.decode(Credential.FromCDDL)(coldCredentialCDDL) + const committeeHotCredential = yield* ParseResult.decode(Credential.FromCDDL)(hotCredentialCDDL) + return yield* ParseResult.decode(Certificate)({ + _tag: "AuthCommitteeHotCert", + committeeColdCredential, + committeeHotCredential + }) + } + case 15n: { + // resign_committee_cold_cert = (15, committee_cold_credential, anchor/ nil) + const [, credentialCDDL, anchorCDDL] = fromA + const committeeColdCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const anchor = anchorCDDL ? yield* ParseResult.decode(Anchor.FromCDDL)(anchorCDDL) : undefined + return yield* ParseResult.decode(Certificate)({ + _tag: "ResignCommitteeColdCert", + committeeColdCredential, + anchor + }) + } + case 16n: { + // reg_drep_cert = (16, drep_credential, coin, anchor/ nil) + const [, credentialCDDL, coinBigInt, anchorCDDL] = fromA + const drepCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const coin = Coin.make(coinBigInt) + const anchor = anchorCDDL ? yield* ParseResult.decode(Anchor.FromCDDL)(anchorCDDL) : undefined + return yield* ParseResult.decode(Certificate)({ _tag: "RegDrepCert", drepCredential, coin, anchor }) + } + case 17n: { + // unreg_drep_cert = (17, drep_credential, coin) + const [, credentialCDDL, coinBigInt] = fromA + const drepCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const coin = Coin.make(coinBigInt) + return yield* ParseResult.decode(Certificate)({ _tag: "UnregDrepCert", drepCredential, coin }) + } + case 18n: { + // update_drep_cert = (18, drep_credential, anchor/ nil) + const [, credentialCDDL, anchorCDDL] = fromA + const drepCredential = yield* ParseResult.decode(Credential.FromCDDL)(credentialCDDL) + const anchor = anchorCDDL ? yield* ParseResult.decode(Anchor.FromCDDL)(anchorCDDL) : undefined + return yield* ParseResult.decode(Certificate)({ _tag: "UpdateDrepCert", drepCredential, anchor }) + } + // default: + // return yield* ParseResult.fail( + // new ParseResult.Type(CDDLSchema.ast, fromA, `Unsupported certificate type ID: ${fromA}`) + // ) + } + }) +}) + +/** + * CBOR bytes transformation schema for Certificate. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + CBOR.FromBytes(options), // Uint8Array → CBOR + FromCDDL // CBOR → Certificate + ) + +/** + * CBOR hex transformation schema for Certificate. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + Bytes.FromHex, // string → Uint8Array + FromCBORBytes(options) // Uint8Array → Certificate + ) + /** * Type alias for Certificate. * @@ -162,3 +534,266 @@ export const Certificate = Schema.Union( * @category model */ export type Certificate = typeof Certificate.Type + +/** + * Check if the given value is a valid Certificate. + * + * @since 2.0.0 + * @category predicates + */ +export const is = Schema.is(Certificate) + +/** + * FastCheck arbitrary for Certificate instances. + * + * @since 2.0.0 + * @category testing + */ +export const arbitrary = FastCheck.oneof( + // StakeRegistration + Credential.arbitrary.map((stakeCredential) => new StakeRegistration({ stakeCredential })), + // StakeDeregistration + Credential.arbitrary.map((stakeCredential) => new StakeDeregistration({ stakeCredential })), + // StakeDelegation + FastCheck.tuple(Credential.arbitrary, PoolKeyHash.arbitrary).map( + ([stakeCredential, poolKeyHash]) => new StakeDelegation({ stakeCredential, poolKeyHash }) + ), + // PoolRegistration + PoolParams.arbitrary.map((poolParams) => new PoolRegistration({ poolParams })), + // PoolRetirement + FastCheck.tuple(PoolKeyHash.arbitrary, EpochNo.generator).map( + ([poolKeyHash, epoch]) => new PoolRetirement({ poolKeyHash, epoch: EpochNo.make(epoch) }) + ), + // RegCert + FastCheck.tuple(Credential.arbitrary, Coin.arbitrary).map( + ([stakeCredential, coin]) => new RegCert({ stakeCredential, coin }) + ), + // UnregCert + FastCheck.tuple(Credential.arbitrary, Coin.arbitrary).map( + ([stakeCredential, coin]) => new UnregCert({ stakeCredential, coin }) + ), + // VoteDelegCert + FastCheck.tuple(Credential.arbitrary, DRep.arbitrary).map( + ([stakeCredential, drep]) => new VoteDelegCert({ stakeCredential, drep }) + ), + // StakeVoteDelegCert + FastCheck.tuple(Credential.arbitrary, PoolKeyHash.arbitrary, DRep.arbitrary).map( + ([stakeCredential, poolKeyHash, drep]) => new StakeVoteDelegCert({ stakeCredential, poolKeyHash, drep }) + ), + // StakeRegDelegCert + FastCheck.tuple(Credential.arbitrary, PoolKeyHash.arbitrary, Coin.arbitrary).map( + ([stakeCredential, poolKeyHash, coin]) => new StakeRegDelegCert({ stakeCredential, poolKeyHash, coin }) + ), + // VoteRegDelegCert + FastCheck.tuple(Credential.arbitrary, DRep.arbitrary, Coin.arbitrary).map( + ([stakeCredential, drep, coin]) => new VoteRegDelegCert({ stakeCredential, drep, coin }) + ), + // StakeVoteRegDelegCert + FastCheck.tuple(Credential.arbitrary, PoolKeyHash.arbitrary, DRep.arbitrary, Coin.arbitrary).map( + ([stakeCredential, poolKeyHash, drep, coin]) => + new StakeVoteRegDelegCert({ stakeCredential, poolKeyHash, drep, coin }) + ), + // AuthCommitteeHotCert + FastCheck.tuple(Credential.arbitrary, Credential.arbitrary).map( + ([committeeColdCredential, committeeHotCredential]) => + new AuthCommitteeHotCert({ committeeColdCredential, committeeHotCredential }) + ), + // ResignCommitteeColdCert + FastCheck.tuple(Credential.arbitrary, FastCheck.option(Anchor.arbitrary, { nil: undefined })).map( + ([committeeColdCredential, anchor]) => new ResignCommitteeColdCert({ committeeColdCredential, anchor }) + ), + // RegDrepCert + FastCheck.tuple(Credential.arbitrary, Coin.arbitrary, FastCheck.option(Anchor.arbitrary, { nil: undefined })).map( + ([drepCredential, coin, anchor]) => new RegDrepCert({ drepCredential, coin, anchor }) + ), + // UnregDrepCert + FastCheck.tuple(Credential.arbitrary, Coin.arbitrary).map( + ([drepCredential, coin]) => new UnregDrepCert({ drepCredential, coin }) + ), + // UpdateDrepCert + FastCheck.tuple(Credential.arbitrary, FastCheck.option(Anchor.arbitrary, { nil: undefined })).map( + ([drepCredential, anchor]) => new UpdateDrepCert({ drepCredential, anchor }) + ) +) + +/** + * Check if two Certificate instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Certificate, b: Certificate): boolean => { + if (a._tag !== b._tag) return false + + switch (a._tag) { + case "StakeRegistration": + return b._tag === "StakeRegistration" && Credential.equals(a.stakeCredential, b.stakeCredential) + case "StakeDeregistration": + return b._tag === "StakeDeregistration" && Credential.equals(a.stakeCredential, b.stakeCredential) + case "StakeDelegation": + return ( + b._tag === "StakeDelegation" && + Credential.equals(a.stakeCredential, b.stakeCredential) && + PoolKeyHash.equals(a.poolKeyHash, b.poolKeyHash) + ) + case "PoolRetirement": + return ( + b._tag === "PoolRetirement" && + PoolKeyHash.equals(a.poolKeyHash, b.poolKeyHash) && + EpochNo.equals(a.epoch, b.epoch) + ) + case "RegCert": + return ( + b._tag === "RegCert" && Credential.equals(a.stakeCredential, b.stakeCredential) && Coin.equals(a.coin, b.coin) + ) + case "UnregCert": + return ( + b._tag === "UnregCert" && Credential.equals(a.stakeCredential, b.stakeCredential) && Coin.equals(a.coin, b.coin) + ) + case "VoteDelegCert": + return ( + b._tag === "VoteDelegCert" && + Credential.equals(a.stakeCredential, b.stakeCredential) && + DRep.equals(a.drep, b.drep) + ) + // Add other cases as needed + default: + return false + } +} + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse a Certificate from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Certificate => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse a Certificate from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Certificate => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a Certificate to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (certificate: Certificate, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(certificate, options)) + +/** + * Convert a Certificate to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (certificate: Certificate, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(certificate, options)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse a Certificate from CBOR bytes. + * + * @since 2.0.0 + * @category effect + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (error) => + new CertificateError({ + message: "Failed to decode Certificate from CBOR bytes", + cause: error + }) + ) + ) + + /** + * Parse a Certificate from CBOR hex string. + * + * @since 2.0.0 + * @category effect + */ + export const fromCBORHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (error) => + new CertificateError({ + message: "Failed to decode Certificate from CBOR hex", + cause: error + }) + ) + ) + + /** + * Convert a Certificate to CBOR bytes. + * + * @since 2.0.0 + * @category effect + */ + export const toCBORBytes = ( + certificate: Certificate, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(certificate).pipe( + Eff.mapError( + (error) => + new CertificateError({ + message: "Failed to encode Certificate to CBOR bytes", + cause: error + }) + ) + ) + + /** + * Convert a Certificate to CBOR hex string. + * + * @since 2.0.0 + * @category effect + */ + export const toCBORHex = ( + certificate: Certificate, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORHex(options))(certificate).pipe( + Eff.mapError( + (error) => + new CertificateError({ + message: "Failed to encode Certificate to CBOR hex", + cause: error + }) + ) + ) +} diff --git a/packages/evolution/src/Coin.ts b/packages/evolution/src/Coin.ts index f2b092e8..0e0028a2 100644 --- a/packages/evolution/src/Coin.ts +++ b/packages/evolution/src/Coin.ts @@ -1,4 +1,4 @@ -import { Data, FastCheck, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" /** * Error class for Coin related operations. @@ -26,7 +26,7 @@ export const MAX_COIN_VALUE = 18446744073709551615n * @since 2.0.0 * @category schemas */ -export const CoinSchema = Schema.BigIntFromSelf.pipe( +export const Coin = Schema.BigIntFromSelf.pipe( Schema.filter((value) => value >= 0n && value <= MAX_COIN_VALUE) ).annotations({ message: (issue) => `Coin must be between 0 and ${MAX_COIN_VALUE}, but got ${issue.actual}`, @@ -40,7 +40,7 @@ export const CoinSchema = Schema.BigIntFromSelf.pipe( * @since 2.0.0 * @category model */ -export type Coin = typeof CoinSchema.Type +export type Coin = typeof Coin.Type /** * Smart constructor for creating Coin values. @@ -48,7 +48,7 @@ export type Coin = typeof CoinSchema.Type * @since 2.0.0 * @category constructors */ -export const make = CoinSchema.make +export const make = Coin.make /** * Check if a value is a valid Coin. @@ -56,7 +56,7 @@ export const make = CoinSchema.make * @since 2.0.0 * @category predicates */ -export const is = Schema.is(CoinSchema) +export const is = Schema.is(Coin) /** * Add two coin amounts safely. @@ -64,15 +64,7 @@ export const is = Schema.is(CoinSchema) * @since 2.0.0 * @category transformation */ -export const add = (a: Coin, b: Coin): Coin => { - const result = a + b - if (result > MAX_COIN_VALUE) { - throw new CoinError({ - message: `Addition overflow: ${a} + ${b} exceeds maximum coin value` - }) - } - return result -} +export const add = (a: Coin, b: Coin): Coin => Eff.runSync(Effect.add(a, b)) /** * Subtract two coin amounts safely. @@ -80,15 +72,7 @@ export const add = (a: Coin, b: Coin): Coin => { * @since 2.0.0 * @category transformation */ -export const subtract = (a: Coin, b: Coin): Coin => { - const result = a - b - if (result < 0n) { - throw new CoinError({ - message: `Subtraction underflow: ${a} - ${b} results in negative value` - }) - } - return result -} +export const subtract = (a: Coin, b: Coin): Coin => Eff.runSync(Effect.subtract(a, b)) /** * Compare two coin amounts. @@ -116,7 +100,55 @@ export const equals = (a: Coin, b: Coin): boolean => a === b * @since 2.0.0 * @category generators */ -export const generator = FastCheck.bigInt({ +export const arbitrary = FastCheck.bigInt({ min: 0n, max: MAX_COIN_VALUE -}) +}).map(make) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Add two coin amounts safely with Effect error handling. + * + * @since 2.0.0 + * @category transformation + */ + export const add = (a: Coin, b: Coin): Eff.Effect => { + const result = a + b + if (result > MAX_COIN_VALUE) { + return Eff.fail( + new CoinError({ + message: `Addition overflow: ${a} + ${b} exceeds maximum coin value` + }) + ) + } + return Eff.succeed(make(result)) + } + + /** + * Subtract two coin amounts safely with Effect error handling. + * + * @since 2.0.0 + * @category transformation + */ + export const subtract = (a: Coin, b: Coin): Eff.Effect => { + const result = a - b + if (result < 0n) { + return Eff.fail( + new CoinError({ + message: `Subtraction underflow: ${a} - ${b} results in negative value` + }) + ) + } + return Eff.succeed(make(result)) + } +} diff --git a/packages/evolution/src/CommitteeColdCredential.ts b/packages/evolution/src/CommitteeColdCredential.ts index 5dc1a00d..637810c2 100644 --- a/packages/evolution/src/CommitteeColdCredential.ts +++ b/packages/evolution/src/CommitteeColdCredential.ts @@ -7,49 +7,6 @@ * @since 2.0.0 */ -import type * as CBOR from "./CBOR.js" import * as Credential from "./Credential.js" -/** - * Error class for CommitteeColdCredential operations - re-exports CredentialError. - * - * @since 2.0.0 - * @category errors - */ -export const CommitteeColdCredentialError = Credential.CredentialError - -/** - * CommitteeColdCredential schema - alias for the Credential schema. - * committee_cold_credential = credential - * - * @since 2.0.0 - * @category schemas - */ -export const CommitteeColdCredential = Credential.Credential - -/** - * Type representing a committee cold credential - alias for Credential type. - * - * @since 2.0.0 - * @category model - */ -export type CommitteeColdCredential = Credential.Credential - -/** - * Re-exported utilities from Credential module. - * - * @since 2.0.0 - */ -export const is = Credential.is -export const equals = Credential.equals -export const generator = Credential.generator -export const Codec = (options?: CBOR.CodecOptions) => Credential.Codec(options) - -/** - * CBOR encoding/decoding schemas. - * - * @since 2.0.0 - * @category schemas - */ -export const FromCBORBytes = Credential.FromCBORBytes -export const FromCBORHex = Credential.FromCBORHex +export const CommitteeColdCredential = Credential diff --git a/packages/evolution/src/CommitteeHotCredential.ts b/packages/evolution/src/CommitteeHotCredential.ts index d7cb6c28..981a698e 100644 --- a/packages/evolution/src/CommitteeHotCredential.ts +++ b/packages/evolution/src/CommitteeHotCredential.ts @@ -9,46 +9,4 @@ import * as Credential from "./Credential.js" -/** - * Error class for CommitteeHotCredential operations - re-exports CredentialError. - * - * @since 2.0.0 - * @category errors - */ -export const CommitteeHotCredentialError = Credential.CredentialError - -/** - * CommitteeHotCredential schema - alias for the Credential schema. - * committee_hot_credential = credential - * - * @since 2.0.0 - * @category schemas - */ -export const CommitteeHotCredential = Credential.Credential - -/** - * Type representing a committee hot credential - alias for Credential type. - * - * @since 2.0.0 - * @category model - */ -export type CommitteeHotCredential = Credential.Credential - -/** - * Re-exported utilities from Credential module. - * - * @since 2.0.0 - */ -export const is = Credential.is -export const equals = Credential.equals -export const generator = Credential.generator -export const Codec = Credential.Codec - -/** - * CBOR encoding/decoding schemas. - * - * @since 2.0.0 - * @category schemas - */ -export const FromCBORBytes = Credential.FromCBORBytes -export const FromCBORHex = Credential.FromCBORHex +export const CommitteeHotCredential = Credential diff --git a/packages/evolution/src/Credential.ts b/packages/evolution/src/Credential.ts index e5bc98ca..d0382457 100644 --- a/packages/evolution/src/Credential.ts +++ b/packages/evolution/src/Credential.ts @@ -1,8 +1,7 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as KeyHash from "./KeyHash.js" import * as ScriptHash from "./ScriptHash.js" @@ -52,7 +51,16 @@ export type Credential = typeof Credential.Type */ export const is = Schema.is(Credential) -export const CDDL = Schema.Tuple( +/** + * Smart constructors for Credential variants. + * + * @since 2.0.0 + * @category constructors + */ +export const makeKeyHash = (hash: KeyHash.KeyHash): Credential => ({ _tag: "KeyHash", hash }) +export const makeScriptHash = (hash: ScriptHash.ScriptHash): Credential => ({ _tag: "ScriptHash", hash }) + +export const CDDLSchema = Schema.Tuple( Schema.Literal(0n, 1n), Schema.Uint8ArrayFromSelf // hash bytes ) @@ -64,57 +72,48 @@ export const CDDL = Schema.Tuple( * @since 2.0.0 * @category schemas */ -export const FromCDDL = Schema.transformOrFail(CDDL, Schema.typeSchema(Credential), { +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Credential), { strict: true, encode: (toI) => - Effect.gen(function* () { + Eff.gen(function* () { switch (toI._tag) { case "KeyHash": { const keyHashBytes = yield* ParseResult.encode(KeyHash.FromBytes)(toI.hash) return [0n, keyHashBytes] as const } case "ScriptHash": { - const scriptHashBytes = yield* ParseResult.encode(ScriptHash.BytesSchema)(toI.hash) + const scriptHashBytes = yield* ParseResult.encode(ScriptHash.FromBytes)(toI.hash) return [1n, scriptHashBytes] as const } } }), decode: ([tag, hashBytes]) => - Effect.gen(function* () { + Eff.gen(function* () { switch (tag) { case 0n: { const keyHash = yield* ParseResult.decode(KeyHash.FromBytes)(hashBytes) return Credential.members[0].make({ hash: keyHash }) } case 1n: { - const scriptHash = yield* ParseResult.decode(ScriptHash.BytesSchema)(hashBytes) + const scriptHash = yield* ParseResult.decode(ScriptHash.FromBytes)(hashBytes) return Credential.members[1].make({ hash: scriptHash }) } } }) }) -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → Credential ) -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array FromCBORBytes(options) // Uint8Array → Credential ) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromCBORBytes(options), - cborHex: FromCBORHex(options) - }, - CredentialError - ) - /** * Check if two Credential instances are equal. * @@ -126,19 +125,119 @@ export const equals = (a: Credential, b: Credential): boolean => { } /** - * Generate a random Credential. + * FastCheck arbitrary for generating random Credential instances. * Randomly selects between generating a KeyHash or ScriptHash credential. * * @since 2.0.0 - * @category generators + * @category testing */ -export const generator = FastCheck.oneof( +export const arbitrary = FastCheck.oneof( FastCheck.record({ _tag: FastCheck.constant("KeyHash" as const), - hash: KeyHash.generator + hash: KeyHash.arbitrary }), FastCheck.record({ _tag: FastCheck.constant("ScriptHash" as const), - hash: ScriptHash.generator + hash: ScriptHash.arbitrary }) ) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse a Credential from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Credential => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse a Credential from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Credential => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Convert a Credential to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (credential: Credential, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(credential, options)) + +/** + * Convert a Credential to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (credential: Credential, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(credential, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse a Credential from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (error) => new CredentialError({ message: "Failed to decode Credential from CBOR bytes", cause: error }) + ) + + /** + * Parse a Credential from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (error) => new CredentialError({ message: "Failed to decode Credential from CBOR hex", cause: error }) + ) + + /** + * Convert a Credential to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = (credential: Credential, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(credential), + (error) => new CredentialError({ message: "Failed to encode Credential to CBOR bytes", cause: error }) + ) + + /** + * Convert a Credential to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = (credential: Credential, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(credential), + (error) => new CredentialError({ message: "Failed to encode Credential to CBOR hex", cause: error }) + ) +} diff --git a/packages/evolution/src/DRep.ts b/packages/evolution/src/DRep.ts index 403ad749..eefda162 100644 --- a/packages/evolution/src/DRep.ts +++ b/packages/evolution/src/DRep.ts @@ -1,8 +1,7 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as KeyHash from "./KeyHash.js" import * as ScriptHash from "./ScriptHash.js" @@ -14,7 +13,7 @@ import * as ScriptHash from "./ScriptHash.js" */ export class DRepError extends Data.TaggedError("DRepError")<{ message?: string - reason?: "InvalidStructure" | "UnsupportedType" + cause?: unknown }> {} /** @@ -44,6 +43,13 @@ export const DRep = Schema.Union( */ export type DRep = typeof DRep.Type +export const CDDLSchema = Schema.Union( + Schema.Tuple(Schema.Literal(0n), Schema.Uint8ArrayFromSelf), + Schema.Tuple(Schema.Literal(1n), Schema.Uint8ArrayFromSelf), + Schema.Tuple(Schema.Literal(2n)), + Schema.Tuple(Schema.Literal(3n)) +) + /** * CDDL schema for DRep with proper transformation. * drep = [0, addr_keyhash] / [1, script_hash] / [2] / [3] @@ -51,67 +57,58 @@ export type DRep = typeof DRep.Type * @since 2.0.0 * @category schemas */ -export const DRepCDDLSchema = Schema.transformOrFail( - Schema.Union( - Schema.Tuple(Schema.Literal(0), Schema.Uint8ArrayFromSelf), - Schema.Tuple(Schema.Literal(1), Schema.Uint8ArrayFromSelf), - Schema.Tuple(Schema.Literal(2)), - Schema.Tuple(Schema.Literal(3)) - ), - Schema.typeSchema(DRep), - { - strict: true, - encode: (toA) => - Effect.gen(function* () { - switch (toA._tag) { - case "KeyHashDRep": { - const keyHashBytes = yield* ParseResult.encode(KeyHash.FromBytes)(toA.keyHash) - return [0, keyHashBytes] as const - } - case "ScriptHashDRep": { - const scriptHashBytes = yield* ParseResult.encode(ScriptHash.BytesSchema)(toA.scriptHash) - return [1, scriptHashBytes] as const - } - case "AlwaysAbstainDRep": - return [2] as const - case "AlwaysNoConfidenceDRep": - return [3] as const +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(DRep), { + strict: true, + encode: (toA) => + Eff.gen(function* () { + switch (toA._tag) { + case "KeyHashDRep": { + const keyHashBytes = yield* ParseResult.encode(KeyHash.FromBytes)(toA.keyHash) + return [0n, keyHashBytes] as const } - }), - decode: (fromA) => - Effect.gen(function* () { - const [tag, ...rest] = fromA - switch (tag) { - case 0: { - const keyHash = yield* ParseResult.decode(KeyHash.FromBytes)(rest[0] as Uint8Array) - return yield* ParseResult.decode(DRep)({ - _tag: "KeyHashDRep", - keyHash - }) - } - case 1: { - const scriptHash = yield* ParseResult.decode(ScriptHash.BytesSchema)(rest[0] as Uint8Array) - return yield* ParseResult.decode(DRep)({ - _tag: "ScriptHashDRep", - scriptHash - }) - } - case 2: - return yield* ParseResult.decode(DRep)({ - _tag: "AlwaysAbstainDRep" - }) - case 3: - return yield* ParseResult.decode(DRep)({ - _tag: "AlwaysNoConfidenceDRep" - }) - default: - return yield* ParseResult.fail( - new ParseResult.Type(Schema.typeSchema(DRep).ast, fromA, `Invalid DRep tag: ${tag}`) - ) + case "ScriptHashDRep": { + const scriptHashBytes = yield* ParseResult.encode(ScriptHash.FromBytes)(toA.scriptHash) + return [1n, scriptHashBytes] as const } - }) - } -) + case "AlwaysAbstainDRep": + return [2n] as const + case "AlwaysNoConfidenceDRep": + return [3n] as const + } + }), + decode: (fromA) => + Eff.gen(function* () { + const [tag, ...rest] = fromA + switch (tag) { + case 0n: { + const keyHash = yield* ParseResult.decode(KeyHash.FromBytes)(rest[0] as Uint8Array) + return yield* ParseResult.decode(DRep)({ + _tag: "KeyHashDRep", + keyHash + }) + } + case 1n: { + const scriptHash = yield* ParseResult.decode(ScriptHash.FromBytes)(rest[0] as Uint8Array) + return yield* ParseResult.decode(DRep)({ + _tag: "ScriptHashDRep", + scriptHash + }) + } + case 2n: + return yield* ParseResult.decode(DRep)({ + _tag: "AlwaysAbstainDRep" + }) + case 3n: + return yield* ParseResult.decode(DRep)({ + _tag: "AlwaysNoConfidenceDRep" + }) + default: + return yield* ParseResult.fail( + new ParseResult.Type(Schema.typeSchema(DRep).ast, fromA, `Invalid DRep tag: ${tag}`) + ) + } + }) +}) /** * Type alias for KeyHashDRep. @@ -151,10 +148,10 @@ export type AlwaysNoConfidenceDRep = Extract +export const FromBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR - DRepCDDLSchema // CBOR → DRep + FromCDDL // CBOR → DRep ) /** @@ -163,107 +160,166 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array FromBytes(options) // Uint8Array → DRep ) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - DRepError - ) - /** - * Pattern match on a DRep to handle different DRep types. + * Check if the given value is a valid DRep * * @since 2.0.0 - * @category transformation + * @category predicates */ -export const match = ( - drep: DRep, - cases: { - KeyHashDRep: (drep: KeyHashDRep) => A - ScriptHashDRep: (drep: ScriptHashDRep) => B - AlwaysAbstainDRep: (drep: AlwaysAbstainDRep) => C - AlwaysNoConfidenceDRep: (drep: AlwaysNoConfidenceDRep) => D - } -): A | B | C | D => { - switch (drep._tag) { - case "KeyHashDRep": - return cases.KeyHashDRep(drep) - case "ScriptHashDRep": - return cases.ScriptHashDRep(drep) - case "AlwaysAbstainDRep": - return cases.AlwaysAbstainDRep(drep) - case "AlwaysNoConfidenceDRep": - return cases.AlwaysNoConfidenceDRep(drep) - default: - throw new Error(`Exhaustive check failed: Unhandled case '${(drep as { _tag: string })._tag}' encountered.`) - } -} +export const isDRep = Schema.is(DRep) /** - * Check if a DRep is a KeyHashDRep. + * FastCheck arbitrary for generating random DRep instances. * * @since 2.0.0 - * @category predicates + * @category arbitrary */ -export const isKeyHashDRep = (drep: DRep): drep is KeyHashDRep => drep._tag === "KeyHashDRep" +export const arbitrary = FastCheck.oneof( + FastCheck.record({ + keyHash: KeyHash.arbitrary + }).map((props) => ({ _tag: "KeyHashDRep" as const, ...props })), + FastCheck.record({ + scriptHash: ScriptHash.arbitrary + }).map((props) => ({ _tag: "ScriptHashDRep" as const, ...props })), + FastCheck.record({}).map(() => ({ _tag: "AlwaysAbstainDRep" as const })), + FastCheck.record({}).map(() => ({ _tag: "AlwaysNoConfidenceDRep" as const })) +) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Check if a DRep is a ScriptHashDRep. + * Parse DRep from CBOR bytes. * * @since 2.0.0 - * @category predicates + * @category parsing */ -export const isScriptHashDRep = (drep: DRep): drep is ScriptHashDRep => drep._tag === "ScriptHashDRep" +export const fromBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): DRep => + Eff.runSync(Effect.fromBytes(bytes, options)) /** - * Check if a DRep is an AlwaysAbstainDRep. + * Parse DRep from CBOR hex string. * * @since 2.0.0 - * @category predicates + * @category parsing */ -export const isAlwaysAbstainDRep = (drep: DRep): drep is AlwaysAbstainDRep => drep._tag === "AlwaysAbstainDRep" +export const fromHex = (hex: string, options?: CBOR.CodecOptions): DRep => Eff.runSync(Effect.fromHex(hex, options)) /** - * Check if a DRep is an AlwaysNoConfidenceDRep. + * Encode DRep to CBOR bytes. * * @since 2.0.0 - * @category predicates + * @category encoding */ -export const isAlwaysNoConfidenceDRep = (drep: DRep): drep is AlwaysNoConfidenceDRep => - drep._tag === "AlwaysNoConfidenceDRep" +export const toBytes = (drep: DRep, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toBytes(drep, options)) /** - * Check if the given value is a valid DRep + * Encode DRep to CBOR hex string. * * @since 2.0.0 - * @category predicates + * @category encoding */ -export const isDRep = Schema.is(DRep) +export const toHex = (drep: DRep, options?: CBOR.CodecOptions): string => Eff.runSync(Effect.toHex(drep, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ /** - * FastCheck generator for DRep instances. + * Effect-based error handling variants for functions that can fail. * * @since 2.0.0 - * @category generators + * @category effect */ -export const generator = FastCheck.oneof( - FastCheck.record({ - keyHash: KeyHash.generator - }).map((props) => ({ _tag: "KeyHashDRep" as const, ...props })), - FastCheck.record({ - scriptHash: ScriptHash.generator - }).map((props) => ({ _tag: "ScriptHashDRep" as const, ...props })), - FastCheck.record({}).map(() => ({ _tag: "AlwaysAbstainDRep" as const })), - FastCheck.record({}).map(() => ({ _tag: "AlwaysNoConfidenceDRep" as const })) -) +export namespace Effect { + /** + * Parse DRep from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new DRepError({ + message: "Failed to parse DRep from bytes", + cause + }) + ) + ) + + /** + * Parse DRep from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new DRepError({ + message: "Failed to parse DRep from hex", + cause + }) + ) + ) + + /** + * Encode DRep to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = ( + drep: DRep, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromBytes(options))(drep).pipe( + Eff.mapError( + (cause) => + new DRepError({ + message: "Failed to encode DRep to bytes", + cause + }) + ) + ) + + /** + * Encode DRep to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = ( + drep: DRep, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromHex(options))(drep).pipe( + Eff.mapError( + (cause) => + new DRepError({ + message: "Failed to encode DRep to hex", + cause + }) + ) + ) +} /** * Check if two DRep instances are equal. @@ -328,3 +384,62 @@ export const alwaysAbstain = (): AlwaysAbstainDRep => ({ export const alwaysNoConfidence = (): AlwaysNoConfidenceDRep => ({ _tag: "AlwaysNoConfidenceDRep" }) + +/** + * Pattern match over DRep. + * + * @since 2.0.0 + * @category pattern matching + */ +export const match = + (patterns: { + KeyHashDRep: (keyHash: KeyHash.KeyHash) => A + ScriptHashDRep: (scriptHash: ScriptHash.ScriptHash) => A + AlwaysAbstainDRep: () => A + AlwaysNoConfidenceDRep: () => A + }) => + (drep: DRep) => { + switch (drep._tag) { + case "KeyHashDRep": + return patterns.KeyHashDRep(drep.keyHash) + case "ScriptHashDRep": + return patterns.ScriptHashDRep(drep.scriptHash) + case "AlwaysAbstainDRep": + return patterns.AlwaysAbstainDRep() + case "AlwaysNoConfidenceDRep": + return patterns.AlwaysNoConfidenceDRep() + } + } + +/** + * Check if DRep is a KeyHashDRep. + * + * @since 2.0.0 + * @category type guards + */ +export const isKeyHashDRep = (drep: DRep): drep is KeyHashDRep => drep._tag === "KeyHashDRep" + +/** + * Check if DRep is a ScriptHashDRep. + * + * @since 2.0.0 + * @category type guards + */ +export const isScriptHashDRep = (drep: DRep): drep is ScriptHashDRep => drep._tag === "ScriptHashDRep" + +/** + * Check if DRep is an AlwaysAbstainDRep. + * + * @since 2.0.0 + * @category type guards + */ +export const isAlwaysAbstainDRep = (drep: DRep): drep is AlwaysAbstainDRep => drep._tag === "AlwaysAbstainDRep" + +/** + * Check if DRep is an AlwaysNoConfidenceDRep. + * + * @since 2.0.0 + * @category type guards + */ +export const isAlwaysNoConfidenceDRep = (drep: DRep): drep is AlwaysNoConfidenceDRep => + drep._tag === "AlwaysNoConfidenceDRep" diff --git a/packages/evolution/src/DRepCredential.ts b/packages/evolution/src/DRepCredential.ts index 3835a28e..8bff6165 100644 --- a/packages/evolution/src/DRepCredential.ts +++ b/packages/evolution/src/DRepCredential.ts @@ -9,46 +9,4 @@ import * as Credential from "./Credential.js" -/** - * Error class for DRepCredential operations - re-exports CredentialError. - * - * @since 2.0.0 - * @category errors - */ -export const DRepCredentialError = Credential.CredentialError - -/** - * DRepCredential schema - alias for the Credential schema. - * drep_credential = credential - * - * @since 2.0.0 - * @category schemas - */ -export const DRepCredential = Credential.Credential - -/** - * Type representing a DRep credential - alias for Credential type. - * - * @since 2.0.0 - * @category model - */ -export type DRepCredential = Credential.Credential - -/** - * Re-exported utilities from Credential module. - * - * @since 2.0.0 - */ -export const isCredential = Credential.is -export const equals = Credential.equals -export const generator = Credential.generator -export const Codec = Credential.Codec - -/** - * CBOR encoding/decoding schemas. - * - * @since 2.0.0 - * @category schemas - */ -export const FromBytes = Credential.FromCBORBytes -export const FromHex = Credential.FromCBORHex +export const DRepCredential = Credential diff --git a/packages/evolution/src/Data.ts b/packages/evolution/src/Data.ts index 0613c3b6..143090be 100644 --- a/packages/evolution/src/Data.ts +++ b/packages/evolution/src/Data.ts @@ -1,8 +1,7 @@ -import { Data as EffectData, Effect, FastCheck, ParseResult, pipe, Schema } from "effect" +import { Data as EffectData, Effect, Either as E, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as Numeric from "./Numeric.js" /** @@ -47,7 +46,7 @@ export class DataError extends EffectData.TaggedError("DataError")<{ * @since 2.0.0 * @category model */ -export type Data = Constr | MapList | List | Int | ByteArray +export type Data = Constr | Map | List | Int | ByteArray /** * Constr type for constructor alternatives based on Conway CDDL specification @@ -77,7 +76,7 @@ export type Data = Constr | MapList | List | Int | ByteArray // readonly fields: readonly Data[]; // } -export type MapList = Map +export type Map = globalThis.Map /** * PlutusList type for plutus data lists @@ -99,8 +98,16 @@ export type List = ReadonlyArray // fields: Schema.Array(Schema.suspend((): Schema.Schema => DataSchema)), // }); export class Constr extends Schema.Class("Constr")({ - index: Numeric.Uint64Schema, - fields: Schema.Array(Schema.suspend((): Schema.Schema => DataSchema)) + index: Numeric.Uint64Schema.annotations({ + identifier: "Data.Constr.Index", + title: "Constructor Index", + description: "The index of the constructor, must be a non-negative integer" + }), + fields: Schema.Array(Schema.suspend((): Schema.Schema => DataSchema)).annotations({ + identifier: "Data.Constr.Fields", + title: "Fields of Constr", + description: "A list of PlutusData fields for the constructor" + }) }) {} /** @@ -111,10 +118,20 @@ export class Constr extends Schema.Class("Constr")({ * @since 2.0.0 */ export const MapSchema = Schema.MapFromSelf({ - key: Schema.suspend((): Schema.Schema => DataSchema), - value: Schema.suspend((): Schema.Schema => DataSchema) + key: Schema.suspend((): Schema.Schema => DataSchema).annotations({ + identifier: "Data.Map.Key", + title: "Map Key", + description: "The key of the PlutusMap, must be a PlutusData type" + }), + value: Schema.suspend((): Schema.Schema => DataSchema).annotations({ + identifier: "Data.Map.Value", + title: "Map Value", + description: "The value of the PlutusMap, must be a PlutusData type" + }) }).annotations({ - identifier: "Data.Map" + identifier: "Data.Map", + title: "PlutusMap", + description: "A map of PlutusData key-value pairs" }) /** @@ -124,7 +141,9 @@ export const MapSchema = Schema.MapFromSelf({ * * @since 2.0.0 */ -export const ListSchema = Schema.Array(Schema.suspend((): Schema.Schema => DataSchema)) +export const ListSchema = Schema.Array(Schema.suspend((): Schema.Schema => DataSchema)).annotations({ + identifier: "Data.List" +}) /** * Schema for PlutusBigInt data type @@ -159,10 +178,10 @@ export type Int = typeof IntSchema.Type * * @since 2.0.0 */ -export const BytesSchema = Bytes.HexLenientSchema.annotations({ - identifier: "Data.Bytes" +export const ByteArray = Bytes.HexLenientSchema.annotations({ + identifier: "Data.ByteArray" }) -export type ByteArray = typeof BytesSchema.Type +export type ByteArray = typeof ByteArray.Type /** * Combined schema for PlutusData type @@ -176,7 +195,7 @@ export const DataSchema: Schema.Schema = Schema.Union( Schema.typeSchema(MapSchema), ListSchema, Schema.typeSchema(IntSchema), - BytesSchema + ByteArray ).annotations({ identifier: "Data" }) @@ -227,7 +246,7 @@ export const isInt = Schema.is(IntSchema) * * @since 2.0.0 */ -export const isBytes = Schema.is(BytesSchema) +export const isBytes = Schema.is(ByteArray) /** * Creates a constructor with the specified index and data @@ -235,11 +254,12 @@ export const isBytes = Schema.is(BytesSchema) * @since 2.0.0 * @category constructors */ -export const constr = (index: bigint, data: Array): Constr => - new Constr({ - index, - fields: data - }) +export const constr = (index: bigint, fields: Array): Constr => Schema.decodeSync(Constr)({ index, fields }) + +// new Constr({ +// index: Numeric.Uint64Make(index), +// fields: data +// }) /** * Creates a Plutus map from key-value pairs @@ -247,8 +267,8 @@ export const constr = (index: bigint, data: Array): Constr => * @since 2.0.0 * @category constructors */ -export const map = (entries: Array<{ key: Data; value: Data }>): MapList => - new Map(entries.map(({ key, value }) => [key, value])) +export const map = (entries: Array<[key: Data, value: Data]>) => + Schema.decodeSync(MapSchema)(new globalThis.Map(entries)) /** * Creates a Plutus list from items @@ -256,7 +276,7 @@ export const map = (entries: Array<{ key: Data; value: Data }>): MapList => * @since 2.0.0 * @category constructors */ -export const list = (list: Array): List => list +export const list = (list: Array): List => Schema.decodeSync(ListSchema)(list) /** * Creates Plutus big integer @@ -264,7 +284,7 @@ export const list = (list: Array): List => list * @since 2.0.0 * @category constructors */ -export const int = (integer: bigint): Int => integer +export const int = (integer: bigint): Int => Schema.decodeSync(IntSchema)(integer) /** * Creates Plutus bounded bytes from hex string @@ -272,7 +292,7 @@ export const int = (integer: bigint): Int => integer * @since 2.0.0 * @category constructors */ -export const bytearray = (bytes: string): ByteArray => bytes +export const bytearray = (bytes: string): ByteArray => Schema.decodeSync(ByteArray)(bytes) /** * Pattern matching helper for Constr types @@ -338,19 +358,19 @@ export const matchData = ( * * @since 2.0.0 */ -export const genPlutusData = (depth: number = 3): FastCheck.Arbitrary => { +export const arbitraryPlutusData = (depth: number = 3): FastCheck.Arbitrary => { if (depth <= 0) { // Base cases: PlutusBigInt or PlutusBytes - return FastCheck.oneof(genPlutusBigInt(), genPlutusBytes()) + return FastCheck.oneof(arbitraryPlutusBigInt(), arbitraryPlutusBytes()) } // Recursive cases with decreasing depth return FastCheck.oneof( - genPlutusBigInt(), - genPlutusBytes(), - genConstr(depth - 1), - genPlutusList(depth - 1), - genPlutusMap(depth - 1) + arbitraryPlutusBigInt(), + arbitraryPlutusBytes(), + arbitraryConstr(depth - 1), + arbitraryPlutusList(depth - 1), + arbitraryPlutusMap(depth - 1) ) } @@ -361,11 +381,11 @@ export const genPlutusData = (depth: number = 3): FastCheck.Arbitrary => { * * @since 2.0.0 */ -export const genPlutusBytes = (): FastCheck.Arbitrary => +export const arbitraryPlutusBytes = (): FastCheck.Arbitrary => FastCheck.uint8Array({ minLength: 0, // Allow empty arrays (valid for PlutusBytes) maxLength: 32 // Max 32 bytes - }).map((bytes) => bytearray(Bytes.Codec.Decode.bytesLenient(bytes))) + }).map((bytes) => bytearray(Schema.decodeSync(Bytes.FromBytesLenient)(bytes))) /** * Creates an arbitrary that generates PlutusBigInt values @@ -374,7 +394,7 @@ export const genPlutusBytes = (): FastCheck.Arbitrary => * * @since 2.0.0 */ -export const genPlutusBigInt = (): FastCheck.Arbitrary => FastCheck.bigInt().map((value) => int(value)) +export const arbitraryPlutusBigInt = (): FastCheck.Arbitrary => FastCheck.bigInt().map((value) => int(value)) /** * Creates an arbitrary that generates PlutusList values @@ -383,8 +403,8 @@ export const genPlutusBigInt = (): FastCheck.Arbitrary => FastCheck.bigInt( * * @since 2.0.0 */ -export const genPlutusList = (depth: number): FastCheck.Arbitrary => - FastCheck.array(genPlutusData(depth), { +export const arbitraryPlutusList = (depth: number): FastCheck.Arbitrary => + FastCheck.array(arbitraryPlutusData(depth), { minLength: 0, maxLength: 5 }).map((value) => list(value)) @@ -396,10 +416,10 @@ export const genPlutusList = (depth: number): FastCheck.Arbitrary => * * @since 2.0.0 */ -export const genConstr = (depth: number): FastCheck.Arbitrary => +export const arbitraryConstr = (depth: number): FastCheck.Arbitrary => FastCheck.tuple( FastCheck.bigInt({ min: 0n, max: 2n ** 64n - 1n }), - FastCheck.array(genPlutusData(depth), { + FastCheck.array(arbitraryPlutusData(depth), { minLength: 0, maxLength: 5 }) @@ -416,10 +436,10 @@ export const genConstr = (depth: number): FastCheck.Arbitrary => * * @since 2.0.0 */ -export const genPlutusMap = (depth: number): FastCheck.Arbitrary => { +export const arbitraryPlutusMap = (depth: number): FastCheck.Arbitrary => { // Helper to create key-value pairs with unique keys const uniqueKeyValuePairs = (keyGen: FastCheck.Arbitrary, maxSize: number) => - FastCheck.uniqueArray(FastCheck.tuple(keyGen, genPlutusData(depth > 0 ? depth - 1 : 0)), { + FastCheck.uniqueArray(FastCheck.tuple(keyGen, arbitraryPlutusData(depth > 0 ? depth - 1 : 0)), { minLength: 0, maxLength: maxSize * 2, // Generate more than needed to increase chance of unique keys selector: (pair) => { @@ -428,50 +448,60 @@ export const genPlutusMap = (depth: number): FastCheck.Arbitrary => { const keyStr = typeof pair[0] === "bigint" ? String(pair[0]) : JSON.stringify(pair[0]) return keyStr } - }).map((pairs) => pairs.map(([key, value]) => ({ key, value }))) + }) // PlutusBigInt keys (more frequent) - const bigIntPairs = uniqueKeyValuePairs(genPlutusBigInt(), 3) + const bigIntPairs = uniqueKeyValuePairs(arbitraryPlutusBigInt(), 3) // PlutusBytes keys (medium frequency) - const bytesPairs = uniqueKeyValuePairs(genPlutusBytes(), 3) + const bytesPairs = uniqueKeyValuePairs(arbitraryPlutusBytes(), 3) // Complex keys (less frequent) - const complexPairs = uniqueKeyValuePairs(genPlutusData(depth > 1 ? depth - 2 : 0), 2) + const complexPairs = uniqueKeyValuePairs(arbitraryPlutusData(depth > 1 ? depth - 2 : 0), 2) return FastCheck.oneof(bigIntPairs, bytesPairs, complexPairs).map((pairs) => map(pairs)) } /** - * FastCheck generators for PlutusData types + * FastCheck arbitrary for PlutusData types * * @since 2.0.0 * @category generators */ -export const generator = genPlutusData(3) +export const arbitrary = arbitraryPlutusData(3) + +// ============================================================================ +// Transformations +// ============================================================================ /** - * CBOR value representation for PlutusData - * This represents the intermediate CBOR structure that corresponds to PlutusData + * Default CBOR options for Data encoding/decoding * * @since 2.0.0 - * @category model + * @category constants */ +export const DEFAULT_CBOR_OPTIONS = CBOR.CML_DATA_DEFAULT_OPTIONS -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - Schema.transformOrFail(Schema.Uint8ArrayFromSelf, DataSchema, { - strict: true, - encode: (toI) => - pipe(plutusDataToCBORValue(toI), (cborValue) => ParseResult.encode(CBOR.FromBytes(options))(cborValue)), - decode: (fromI) => pipe(ParseResult.decode(CBOR.FromBytes(options))(fromI), Effect.map(cborValueToPlutusData)) - }).annotations({ - identifier: "Data.FromCBORBytes" - }) +/** + * Convert a big-endian byte array to a positive bigint + * Used for CBOR tag 2/3 decoding + */ +const bytesToBigint = (bytes: Uint8Array): bigint => { + if (bytes.length === 0) { + return 0n + } -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - Schema.compose(Bytes.FromHex, FromCBORBytes(options)).annotations({ - identifier: "Data.FromCBORHex" - }) + let result = 0n + for (let i = 0; i < bytes.length; i++) { + result = (result << 8n) | BigInt(bytes[i]) + } + + return result +} + +// ============================================================================ +// Combinators +// ============================================================================ /** * Convert PlutusData to CBORValue @@ -498,7 +528,7 @@ export const plutusDataToCBORValue = (data: Data): CBOR.CBOR => { return value }, Bytes: (bytes): CBOR.CBOR => { - return Bytes.Codec.Encode.bytesLenient(bytes) + return Schema.encodeSync(Bytes.FromBytesLenient)(bytes) }, Constr: (constr): CBOR.CBOR => { // PlutusData Constr -> CBOR tags based on index @@ -507,19 +537,19 @@ export const plutusDataToCBORValue = (data: Data): CBOR.CBOR => { if (constr.index >= 0n && constr.index <= 6n) { // Direct encoding for constructor indices 0-6 (tags 121-127) - return new CBOR.Tag({ + return CBOR.Tag.make({ tag: Number(121n + constr.index), value: fieldsArray }) } else if (constr.index >= 7n && constr.index <= 127n) { // Alternative encoding for constructor indices 7-127 (tag 1280+index-7) - return new CBOR.Tag({ + return CBOR.Tag.make({ tag: Number(1280n + constr.index - 7n), value: fieldsArray }) } else { // General constructor encoding for any uint value (tag 102) - return new CBOR.Tag({ + return CBOR.Tag.make({ tag: 102, value: [constr.index, fieldsArray] }) @@ -546,7 +576,7 @@ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { if (cborValue.length === 0) { return "" } - return Bytes.Codec.Decode.bytes(cborValue) + return Schema.decodeSync(Bytes.FromBytes)(cborValue) } // Handle tagged values @@ -562,7 +592,7 @@ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { }) } const fields = value.map(cborValueToPlutusData) - return new Constr({ index: BigInt(tag - 121), fields }) + return new Constr({ index: Numeric.Uint64Make(BigInt(tag - 121)), fields }) } // Handle alternative constructor tags (1280-1400 for indices 7-127) @@ -573,7 +603,7 @@ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { }) } const fields = value.map(cborValueToPlutusData) - return new Constr({ index: BigInt(tag - 1280 + 7), fields }) + return new Constr({ index: Numeric.Uint64Make(BigInt(tag - 1280 + 7)), fields }) } // Handle general constructor tag (102) @@ -602,7 +632,7 @@ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { } const fields = fieldsValue.map(cborValueToPlutusData) - return new Constr({ index: indexValue, fields }) + return new Constr({ index: Numeric.Uint64Make(indexValue), fields }) } } @@ -654,76 +684,419 @@ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { }) } +export const CDDLSchema = CBOR.CBORSchema + /** - * Convert a big-endian byte array to a positive bigint - * Used for CBOR tag 2/3 decoding + * CDDL schema for PlutusData following the Conway specification. + * + * ``` + * plutus_data = + * constr + * / {* plutus_data => plutus_data} + * / [* plutus_data] + * / big_int + * / bounded_bytes + * + * constr = + * #6.121([* a0]) // index 0 + * / #6.122([* a0]) // index 1 + * / #6.123([* a0]) // index 2 + * / #6.124([* a0]) // index 3 + * / #6.125([* a0]) // index 4 + * / #6.126([* a0]) // index 5 + * / #6.127([* a0]) // index 6 + * / #6.102([uint, [* a0]]) // general constructor + * + * big_int = int / big_uint / big_nint + * big_uint = #6.2(bounded_bytes) + * big_nint = #6.3(bounded_bytes) + * ``` + * + * This transforms between CBOR values and PlutusData using the existing + * plutusDataToCBORValue and cborValueToPlutusData functions. + * + * @since 2.0.0 + * @category schemas */ -const bytesToBigint = (bytes: Uint8Array): bigint => { - if (bytes.length === 0) { - return 0n +export const FromCDDL = Schema.transformOrFail(CDDLSchema, DataSchema, { + strict: true, + encode: (_, __, ___, data) => Effect.succeed(plutusDataToCBORValue(data)), + decode: (_, __, ___, cborValue) => + Effect.try({ + try: () => cborValueToPlutusData(cborValue), + catch: (error) => new ParseResult.Type(DataSchema.ast, cborValue, String(error)) + }) +}) + +/** + * CBOR bytes transformation schema for PlutusData using CDDL. + * Transforms between CBOR bytes and Data using CDDL encoding. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS) => + Schema.compose( + CBOR.FromBytes(options), // Uint8Array → CBOR + FromCDDL // CBOR → Data + ).annotations({ + identifier: "Data.FromCBORBytes", + title: "Data from CBOR Bytes using CDDL", + description: "Transforms CBOR bytes to Data using CDDL encoding" + }) + +/** + * CBOR hex transformation schema for PlutusData using CDDL. + * Transforms between CBOR hex string and Data using CDDL encoding. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS) => + Schema.compose( + Bytes.FromHex, // string → Uint8Array + FromCBORBytes(options) // Uint8Array → Data + ).annotations({ + identifier: "Data.FromCBORHex", + title: "Data from CBOR Hex using CDDL", + description: "Transforms CBOR hex string to Data using CDDL encoding" + }) + +// ============================================================================ +// Either Namespace +// ============================================================================ + +/** + * Either-based variants for functions that can fail. + * + * @since 2.0.0 + * @category either + */ +export namespace Either { + /** + * Encode PlutusData to CBOR bytes with Either error handling + * + * @since 2.0.0 + * @category transformation + */ + export const toCBORBytes = (data: Data, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS) => + E.mapLeft( + Schema.encodeEither(FromCBORBytes(options))(data), + (cause) => + new DataError({ + message: "Failed to encode to CBOR bytes", + cause + }) + ) + + /** + * Encode PlutusData to CBOR hex string with Either error handling + * + * @since 2.0.0 + * @category transformation + */ + export const toCBORHex = (data: Data, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS) => + E.mapLeft( + Schema.encodeEither(FromCBORHex(options))(data), + (cause) => + new DataError({ + message: "Failed to encode to CBOR hex", + cause + }) + ) + + /** + * Decode PlutusData from CBOR bytes with Either error handling + * + * @since 2.0.0 + * @category transformation + */ + export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS) => + E.mapLeft( + Schema.decodeEither(FromCBORBytes(options))(bytes), + (cause) => + new DataError({ + message: "Failed to decode CBOR bytes", + cause + }) + ) + + /** + * Decode PlutusData from CBOR hex string with Effect error handling + * + * @since 2.0.0 + * @category transformation + */ + export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS) => + E.mapLeft( + Schema.decodeEither(FromCBORHex(options))(hex), + (cause) => new DataError({ message: "Failed to decode CBOR hex", cause }) + ) + + /** + * Transform data to Data using a schema with Either error handling + * + * @since 2.0.0 + * @category transformation + */ + export const toData = + (schema: Schema.Schema) => + (data: A) => + E.mapLeft( + Schema.encodeEither(schema)(data), + (cause) => + new DataError({ + message: "Failed to encode to Data", + cause + }) + ) + + /** + * Transform Data back from a schema with Either error handling + * + * @since 2.0.0 + * @category transformation + */ + export const fromData = + (schema: Schema.Schema) => + (data: Data) => + E.mapLeft( + Schema.decodeEither(schema)(data), + (cause) => + new DataError({ + message: "Failed to decode from Data", + cause + }) + ) + + /** + * Create a schema that transforms from a custom type to Data and provides CBOR encoding + * + * @since 2.0.0 + * @category combinators + */ + export const withSchema = ( + schema: Schema.Schema, + options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS + ) => { + return { + toData: (A: A) => E.mapLeft(Schema.encodeEither(schema)(A), (error) => new DataError({ cause: error })), + fromData: (data: Data) => + E.mapLeft(Schema.decodeEither(schema)(data as I), (error) => new DataError({ cause: error })), + toCBORHex: (A: A) => + E.mapLeft( + Schema.encodeEither(FromCBORHex(options))(Schema.encodeSync(schema)(A) as Data), + (error) => new DataError({ cause: error }) + ), + toCBORBytes: (A: A) => + E.mapLeft( + Schema.encodeEither(FromCBORBytes(options))(Schema.encodeSync(schema)(A) as Data), + (error) => new DataError({ cause: error }) + ), + fromCBORHex: (hex: string) => + E.mapLeft(Schema.decodeEither(FromCBORHex(options))(hex), (error) => new DataError({ cause: error })).pipe( + E.flatMap((data) => + Schema.decodeEither(schema)(data as I).pipe(E.mapLeft((error) => new DataError({ cause: error }))) + ) + ), + fromCBORBytes: (bytes: Uint8Array) => + E.mapLeft(Schema.decodeEither(FromCBORBytes(options))(bytes), (error) => new DataError({ cause: error })).pipe( + E.flatMap((data) => + Schema.decodeEither(schema)(data as I).pipe(E.mapLeft((error) => new DataError({ cause: error }))) + ) + ) + } } +} - let result = 0n - for (let i = 0; i < bytes.length; i++) { - result = (result << 8n) | BigInt(bytes[i]) +/** + * Encode PlutusData to CBOR bytes + * + * @since 2.0.0 + * @category transformation + */ +export const toCBORBytes = (data: Data, options?: CBOR.CodecOptions): Uint8Array => { + try { + return Schema.encodeSync(FromCBORBytes(options))(data) + } catch (cause) { + throw new DataError({ + message: "Failed to encode to CBOR bytes", + cause + }) } +} - return result +/** + * Encode PlutusData to CBOR hex string + * + * @since 2.0.0 + * @category transformation + */ +export const toCBORHex = (data: Data, options?: CBOR.CodecOptions): string => { + try { + return Schema.encodeSync(FromCBORHex(options))(data) + } catch (cause) { + throw new DataError({ + message: "Failed to encode to CBOR hex", + cause + }) + } +} + +/** + * Decode PlutusData from CBOR bytes + * + * @since 2.0.0 + * @category transformation + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Data => { + try { + return Schema.decodeSync(FromCBORBytes(options))(bytes) + } catch (cause) { + throw new DataError({ + message: "Failed to decode CBOR bytes", + cause + }) + } } -// Function overloads for better type inference -export function Codec(params: { - schema: Schema.Schema - options?: CBOR.CodecOptions -}): ReturnType< - typeof _Codec.createEncoders< - { - toData: Schema.Schema - cborHex: Schema.Schema - cborBytes: Schema.Schema +/** + * Decode PlutusData from CBOR hex string + * + * @since 2.0.0 + * @category transformation + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Data => { + try { + return Schema.decodeSync(FromCBORHex(options))(hex) + } catch (cause) { + throw new DataError({ + message: "Failed to decode CBOR hex", + cause + }) + } +} + +/** + * Transform data to Data using a schema + * + * @since 2.0.0 + * @category transformation + */ +export const toData = + (schema: Schema.Schema) => + (data: A): Data => { + try { + return Schema.encodeSync(schema)(data) + } catch (cause) { + throw new DataError({ + message: "Failed to encode to Data", + cause + }) + } + } + +/** + * Transform Data back from a schema + * + * @since 2.0.0 + * @category transformation + */ +export const fromData = + (schema: Schema.Schema) => + (data: Data): A => { + try { + return Schema.decodeSync(schema)(data) + } catch (cause) { + throw new DataError({ + message: "Failed to decode from Data", + cause + }) + } + } + +/** + * Create a schema that transforms from a custom type to Data and provides CBOR encoding + * + * @since 2.0.0 + * @category combinators + */ +export const withSchema = (schema: Schema.Schema, options?: CBOR.CodecOptions) => { + return { + toData: (value: A): Data => { + try { + return Schema.encodeSync(schema)(value) as Data + } catch (cause) { + throw new DataError({ + message: "Failed to encode to Data", + cause + }) + } }, - typeof DataError - > -> - -export function Codec(params?: { options?: CBOR.CodecOptions }): ReturnType< - typeof _Codec.createEncoders< - { - cborHex: Schema.Schema - cborBytes: Schema.Schema + fromData: (data: Data): A => { + try { + return Schema.decodeSync(schema)(data as I) + } catch (cause) { + throw new DataError({ + message: "Failed to decode from Data", + cause + }) + } }, - typeof DataError - > -> - -export function Codec(params?: { schema?: Schema.Schema; options?: CBOR.CodecOptions }) { - const schema = params?.schema - const codecOptions = params?.options || CBOR.DEFAULT_OPTIONS - - const FromHex = FromCBORHex(codecOptions) - const FromBytes = FromCBORBytes(codecOptions) - - if (schema) { - // With schema: type A -> Data B -> CBOR - const schemaToHex = Schema.compose(FromHex, schema) - const schemaToBytes = Schema.compose(FromBytes, schema) - - return _Codec.createEncoders( - { - toData: schema, - cborHex: schemaToHex, - cborBytes: schemaToBytes - }, - DataError - ) + toCBORHex: (value: A): string => { + try { + const data = Schema.encodeSync(schema)(value) as Data + return Schema.encodeSync(FromCBORHex(options))(data) + } catch (cause) { + throw new DataError({ + message: "Failed to encode to CBOR hex", + cause + }) + } + }, + toCBORBytes: (value: A): Uint8Array => { + try { + const data = Schema.encodeSync(schema)(value) as Data + return Schema.encodeSync(FromCBORBytes(options))(data) + } catch (cause) { + throw new DataError({ + message: "Failed to encode to CBOR bytes", + cause + }) + } + }, + fromCBORHex: (hex: string): A => { + try { + const data = Schema.decodeSync(FromCBORHex(options))(hex) + return Schema.decodeSync(schema)(data as I) + } catch (cause) { + throw new DataError({ + message: "Failed to decode from CBOR hex", + cause + }) + } + }, + fromCBORBytes: (bytes: Uint8Array): A => { + try { + const data = Schema.decodeSync(FromCBORBytes(options))(bytes) + return Schema.decodeSync(schema)(data as I) + } catch (cause) { + throw new DataError({ + message: "Failed to decode from CBOR bytes", + cause + }) + } + } } +} - // Without schema: Data -> CBOR directly - return _Codec.createEncoders( - { - cborHex: FromHex, - cborBytes: FromBytes - }, - DataError - ) +/** + * Create a codec for a schema that transforms from a custom type to Data and provides CBOR encoding + * This is an alias for withSchema for backward compatibility + * + * @since 2.0.0 + * @category combinators + */ +export const Codec = (config: { schema: Schema.Schema; options?: CBOR.CodecOptions }) => { + return withSchema(config.schema, config.options) } diff --git a/packages/evolution/src/DatumOption.ts b/packages/evolution/src/DatumOption.ts index 4e45e13e..5fb91b03 100644 --- a/packages/evolution/src/DatumOption.ts +++ b/packages/evolution/src/DatumOption.ts @@ -1,9 +1,8 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as Bytes32 from "./Bytes32.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as PlutusData from "./Data.js" /** @@ -116,19 +115,36 @@ export const getData = (datumOption: DatumOption): PlutusData.Data | undefined = isInlineDatum(datumOption) ? datumOption.data : undefined /** - * FastCheck generator for DatumOption instances. + * Check if two DatumOption instances are equal. * * @since 2.0.0 - * @category generators + * @category equality */ -export const generator = FastCheck.oneof( +export const equals = (a: DatumOption, b: DatumOption): boolean => { + if (a._tag !== b._tag) return false + if (a._tag === "DatumHash" && b._tag === "DatumHash") { + return a.hash === b.hash + } + if (a._tag === "InlineDatum" && b._tag === "InlineDatum") { + return a.data === b.data + } + return false +} + +/** + * FastCheck arbitrary for generating random DatumOption instances + * + * @since 2.0.0 + * @category testing + */ +export const arbitrary = FastCheck.oneof( FastCheck.record({ _tag: FastCheck.constant("DatumHash" as const), hash: FastCheck.hexaString({ minLength: 64, maxLength: 64 }) }).map((props) => new DatumHash(props)), FastCheck.record({ _tag: FastCheck.constant("InlineDatum" as const), - data: PlutusData.genPlutusData() + data: PlutusData.arbitrary }).map((props) => new InlineDatum(props)) ) @@ -152,7 +168,7 @@ export const DatumOptionCDDLSchema = Schema.transformOrFail( { strict: true, encode: (toA) => - Effect.gen(function* () { + Eff.gen(function* () { const result = toA._tag === "DatumHash" ? ([0n, yield* ParseResult.encode(Bytes.FromBytes)(toA.hash)] as const) // Encode as [0, Bytes32] @@ -160,7 +176,7 @@ export const DatumOptionCDDLSchema = Schema.transformOrFail( return result }), decode: ([tag, value], _, ast) => - Effect.gen(function* () { + Eff.gen(function* () { if (tag === 0n) { // Decode as DatumHash const hash = yield* ParseResult.decode(Bytes.FromBytes)(value) @@ -176,7 +192,10 @@ export const DatumOptionCDDLSchema = Schema.transformOrFail( ) }) } -) +).annotations({ + identifier: "DatumOption.DatumOptionCDDLSchema", + description: "Transforms CBOR structure to DatumOption" +}) /** * CBOR bytes transformation schema for DatumOption. @@ -185,11 +204,14 @@ export const DatumOptionCDDLSchema = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR DatumOptionCDDLSchema // CBOR → DatumOption - ) + ).annotations({ + identifier: "DatumOption.FromCBORBytes", + description: "Transforms CBOR bytes to DatumOption" + }) /** * CBOR hex transformation schema for DatumOption. @@ -198,17 +220,103 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → DatumOption - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - DatumOptionError - ) + FromCBORBytes(options) // Uint8Array → DatumOption + ).annotations({ + identifier: "DatumOption.FromCBORHex", + description: "Transforms CBOR hex string to DatumOption" + }) + +/** + * Effect namespace for DatumOption operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert CBOR bytes to DatumOption using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (cause) => new DatumOptionError({ message: "Failed to decode from CBOR bytes", cause }) + ) + + /** + * Convert CBOR hex string to DatumOption using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (cause) => new DatumOptionError({ message: "Failed to decode from CBOR hex", cause }) + ) + + /** + * Convert DatumOption to CBOR bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORBytes = (datumOption: DatumOption, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(datumOption), + (cause) => new DatumOptionError({ message: "Failed to encode to CBOR bytes", cause }) + ) + + /** + * Convert DatumOption to CBOR hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORHex = (datumOption: DatumOption, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(datumOption), + (cause) => new DatumOptionError({ message: "Failed to encode to CBOR hex", cause }) + ) +} + +/** + * Convert CBOR bytes to DatumOption (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): DatumOption => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Convert CBOR hex string to DatumOption (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): DatumOption => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Convert DatumOption to CBOR bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORBytes = (datumOption: DatumOption, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(datumOption, options)) + +/** + * Convert DatumOption to CBOR hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORHex = (datumOption: DatumOption, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(datumOption, options)) diff --git a/packages/evolution/src/DnsName.ts b/packages/evolution/src/DnsName.ts index 6ecf794c..6bb35f24 100644 --- a/packages/evolution/src/DnsName.ts +++ b/packages/evolution/src/DnsName.ts @@ -1,6 +1,5 @@ -import { Data, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" -import * as _Codec from "./Codec.js" import * as Text128 from "./Text128.js" /** @@ -56,23 +55,133 @@ export const make = DnsName.make export const equals = (a: DnsName, b: DnsName): boolean => a === b /** - * Generate a random DnsName. + * Check if the given value is a valid DnsName * * @since 2.0.0 - * @category generators + * @category predicates */ -export const generator = Text128.generator.map((text) => make(text)) +export const isDnsName = Schema.is(DnsName) /** - * Codec utilities for DnsName encoding and decoding operations. + * FastCheck arbitrary for generating random DnsName instances. * * @since 2.0.0 - * @category encoding/decoding + * @category arbitrary */ -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - DnsNameError -) +export const arbitrary = Text128.arbitrary.map((text) => make(text)) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse DnsName from bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): DnsName => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse DnsName from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): DnsName => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode DnsName to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (dnsName: DnsName): Uint8Array => Eff.runSync(Effect.toBytes(dnsName)) + +/** + * Encode DnsName to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (dnsName: DnsName): string => Eff.runSync(Effect.toHex(dnsName)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse DnsName from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new DnsNameError({ + message: "Failed to parse DnsName from bytes", + cause + }) + ) + ) + + /** + * Parse DnsName from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new DnsNameError({ + message: "Failed to parse DnsName from hex", + cause + }) + ) + ) + + /** + * Encode DnsName to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (dnsName: DnsName): Eff.Effect => + Schema.encode(FromBytes)(dnsName).pipe( + Eff.mapError( + (cause) => + new DnsNameError({ + message: "Failed to encode DnsName to bytes", + cause + }) + ) + ) + + /** + * Encode DnsName to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (dnsName: DnsName): Eff.Effect => + Schema.encode(FromHex)(dnsName).pipe( + Eff.mapError( + (cause) => + new DnsNameError({ + message: "Failed to encode DnsName to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/Ed25519Signature.ts b/packages/evolution/src/Ed25519Signature.ts index 5961ae8f..aad03c69 100644 --- a/packages/evolution/src/Ed25519Signature.ts +++ b/packages/evolution/src/Ed25519Signature.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes64 from "./Bytes64.js" -import { createEncoders } from "./Codec.js" /** * Error class for Ed25519Signature related operations. @@ -22,7 +21,7 @@ export class Ed25519SignatureError extends Data.TaggedError("Ed25519SignatureErr * @since 2.0.0 * @category schemas */ -export const Ed25519Signature = pipe(Bytes64.HexSchema, Schema.brand("Ed25519Signature")).annotations({ +export const Ed25519Signature = Bytes64.HexSchema.pipe(Schema.brand("Ed25519Signature")).annotations({ identifier: "Ed25519Signature" }) @@ -42,6 +41,14 @@ export const FromHex = Schema.compose( identifier: "Ed25519Signature.Hex" }) +/** + * Smart constructor for Ed25519Signature that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Ed25519Signature.make + /** * Check if two Ed25519Signature instances are equal. * @@ -51,26 +58,136 @@ export const FromHex = Schema.compose( export const equals = (a: Ed25519Signature, b: Ed25519Signature): boolean => a === b /** - * Generate a random Ed25519Signature. + * Check if the given value is a valid Ed25519Signature + * + * @since 2.0.0 + * @category predicates + */ +export const isEd25519Signature = Schema.is(Ed25519Signature) + +/** + * FastCheck arbitrary for generating random Ed25519Signature instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes64.BYTES_LENGTH, - maxLength: Bytes64.BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes64.HEX_LENGTH, + maxLength: Bytes64.HEX_LENGTH +}).map((hex) => hex as Ed25519Signature) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for Ed25519Signature encoding and decoding operations. + * Parse Ed25519Signature from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - Ed25519SignatureError -) +export const fromBytes = (bytes: Uint8Array): Ed25519Signature => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse Ed25519Signature from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): Ed25519Signature => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode Ed25519Signature to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (signature: Ed25519Signature): Uint8Array => Eff.runSync(Effect.toBytes(signature)) + +/** + * Encode Ed25519Signature to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (signature: Ed25519Signature): string => Eff.runSync(Effect.toHex(signature)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse Ed25519Signature from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new Ed25519SignatureError({ + message: "Failed to parse Ed25519Signature from bytes", + cause + }) + ) + ) + + /** + * Parse Ed25519Signature from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new Ed25519SignatureError({ + message: "Failed to parse Ed25519Signature from hex", + cause + }) + ) + ) + + /** + * Encode Ed25519Signature to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (signature: Ed25519Signature): Eff.Effect => + Schema.encode(FromBytes)(signature).pipe( + Eff.mapError( + (cause) => + new Ed25519SignatureError({ + message: "Failed to encode Ed25519Signature to bytes", + cause + }) + ) + ) + + /** + * Encode Ed25519Signature to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (signature: Ed25519Signature): Eff.Effect => + Schema.encode(FromHex)(signature).pipe( + Eff.mapError( + (cause) => + new Ed25519SignatureError({ + message: "Failed to encode Ed25519Signature to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/EnterpriseAddress.ts b/packages/evolution/src/EnterpriseAddress.ts index b7ffbeb5..dd77edc7 100644 --- a/packages/evolution/src/EnterpriseAddress.ts +++ b/packages/evolution/src/EnterpriseAddress.ts @@ -1,8 +1,7 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as Bytes29 from "./Bytes29.js" -import * as _Codec from "./Codec.js" import * as Credential from "./Credential.js" import * as KeyHash from "./KeyHash.js" import * as NetworkId from "./NetworkId.js" @@ -22,20 +21,12 @@ export class EnterpriseAddressError extends Data.TaggedError("EnterpriseAddressE export class EnterpriseAddress extends Schema.TaggedClass("EnterpriseAddress")("EnterpriseAddress", { networkId: NetworkId.NetworkId, paymentCredential: Credential.Credential -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: "EnterpriseAddress", - networkId: this.networkId, - paymentCredential: this.paymentCredential - } - } -} +}) {} export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, EnterpriseAddress, { strict: true, encode: (_, __, ___, toA) => - Effect.gen(function* () { + Eff.gen(function* () { const paymentBit = toA.paymentCredential._tag === "KeyHash" ? 0 : 1 const header = (0b01 << 6) | (0b1 << 5) | (paymentBit << 4) | (toA.networkId & 0b00001111) @@ -48,7 +39,7 @@ export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, EnterpriseA return yield* ParseResult.succeed(result) }), decode: (_, __, ___, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const header = fromA[0] // Extract network ID from the lower 4 bits const networkId = header & 0b00001111 @@ -64,7 +55,7 @@ export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, EnterpriseA } : { _tag: "ScriptHash", - hash: yield* ParseResult.decode(ScriptHash.BytesSchema)(fromA.slice(1, 29)) + hash: yield* ParseResult.decode(ScriptHash.FromBytes)(fromA.slice(1, 29)) } return yield* ParseResult.decode(EnterpriseAddress)({ _tag: "EnterpriseAddress", @@ -72,12 +63,29 @@ export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, EnterpriseA paymentCredential }) }) +}).annotations({ + identifier: "EnterpriseAddress.FromBytes", + description: "Transforms raw bytes to EnterpriseAddress" }) export const FromHex = Schema.compose( Bytes.FromHex, // string → Uint8Array FromBytes // Uint8Array → EnterpriseAddress -) +).annotations({ + identifier: "EnterpriseAddress.FromHex", + description: "Transforms raw hex string to EnterpriseAddress" +}) + +/** + * Smart constructor for creating EnterpriseAddress instances + * + * @since 2.0.0 + * @category constructors + */ +export const make = (props: { + networkId: NetworkId.NetworkId + paymentCredential: Credential.Credential +}): EnterpriseAddress => new EnterpriseAddress(props) /** * Check if two EnterpriseAddress instances are equal. @@ -86,31 +94,103 @@ export const FromHex = Schema.compose( * @category equality */ export const equals = (a: EnterpriseAddress, b: EnterpriseAddress): boolean => { - return ( - a.networkId === b.networkId && - a.paymentCredential._tag === b.paymentCredential._tag && - a.paymentCredential.hash === b.paymentCredential.hash - ) + return a.networkId === b.networkId && Credential.equals(a.paymentCredential, b.paymentCredential) } /** - * Generate a random EnterpriseAddress. + * FastCheck arbitrary for generating random EnterpriseAddress instances * * @since 2.0.0 - * @category generators + * @category testing */ -export const generator = FastCheck.tuple(NetworkId.generator, Credential.generator).map( - ([networkId, paymentCredential]) => - new EnterpriseAddress({ - networkId, - paymentCredential - }) +export const arbitrary = FastCheck.tuple(NetworkId.arbitrary, Credential.arbitrary).map( + ([networkId, paymentCredential]) => make({ networkId, paymentCredential }) ) -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - EnterpriseAddressError -) +/** + * Effect namespace for EnterpriseAddress operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert bytes to EnterpriseAddress using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromBytes = (bytes: Uint8Array) => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => new EnterpriseAddressError({ message: "Failed to decode from bytes", cause }) + ) + + /** + * Convert hex string to EnterpriseAddress using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromHex = (hex: string) => + Eff.mapError( + Schema.decode(FromHex)(hex), + (cause) => new EnterpriseAddressError({ message: "Failed to decode from hex", cause }) + ) + + /** + * Convert EnterpriseAddress to bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toBytes = (address: EnterpriseAddress) => + Eff.mapError( + Schema.encode(FromBytes)(address), + (cause) => new EnterpriseAddressError({ message: "Failed to encode to bytes", cause }) + ) + + /** + * Convert EnterpriseAddress to hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toHex = (address: EnterpriseAddress) => + Eff.mapError( + Schema.encode(FromHex)(address), + (cause) => new EnterpriseAddressError({ message: "Failed to encode to hex", cause }) + ) +} + +/** + * Convert bytes to EnterpriseAddress (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromBytes = (bytes: Uint8Array): EnterpriseAddress => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert hex string to EnterpriseAddress (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromHex = (hex: string): EnterpriseAddress => Eff.runSync(Effect.fromHex(hex)) + +/** + * Convert EnterpriseAddress to bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toBytes = (address: EnterpriseAddress): Uint8Array => Eff.runSync(Effect.toBytes(address)) + +/** + * Convert EnterpriseAddress to hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toHex = (address: EnterpriseAddress): string => Eff.runSync(Effect.toHex(address)) diff --git a/packages/evolution/src/GovernanceAction.ts b/packages/evolution/src/GovernanceAction.ts new file mode 100644 index 00000000..8ecf3726 --- /dev/null +++ b/packages/evolution/src/GovernanceAction.ts @@ -0,0 +1,823 @@ +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" + +import * as CBOR from "./CBOR.js" +import * as Coin from "./Coin.js" +import * as RewardAccount from "./RewardAccount.js" +import * as ScriptHash from "./ScriptHash.js" +import * as TransactionHash from "./TransactionHash.js" +import * as TransactionIndex from "./TransactionIndex.js" + +/** + * Error class for GovernanceAction related operations. + * + * @since 2.0.0 + * @category errors + */ +export class GovernanceActionError extends Data.TaggedError("GovernanceActionError")<{ + message?: string + cause?: unknown +}> {} + +/** + * GovActionId schema representing a governance action identifier. + * According to Conway CDDL: gov_action_id = [transaction_id : transaction_id, gov_action_index : uint .size 2] + * + * @since 2.0.0 + * @category schemas + */ +export class GovActionId extends Schema.TaggedClass()("GovActionId", { + transactionId: TransactionHash.TransactionHash, // transaction_id (hash32) + govActionIndex: TransactionIndex.TransactionIndex // uint .size 2 (governance action index) +}) {} + +/** + * CDDL schema for GovActionId tuple structure. + * For CBOR encoding: [transaction_id: bytes, gov_action_index: uint] + * + * @since 2.0.0 + * @category schemas + */ +export const GovActionIdCDDL = Schema.Tuple( + CBOR.ByteArray, // transaction_id as bytes + CBOR.Integer // gov_action_index as uint +) + +/** + * CDDL transformation schema for GovActionId. + * + * @since 2.0.0 + * @category schemas + */ +export const GovActionIdFromCDDL = Schema.transformOrFail(GovActionIdCDDL, GovActionId, { + strict: true, + encode: (_, __, ___, toA) => + Eff.gen(function* () { + // Convert domain types to CBOR types + const transactionIdBytes = yield* ParseResult.encode(TransactionHash.FromBytes)(toA.transactionId) + const indexNumber = yield* ParseResult.encode(TransactionIndex.TransactionIndex)(toA.govActionIndex) + return [transactionIdBytes, BigInt(indexNumber)] as const + }), + decode: (fromA) => + Eff.gen(function* () { + const [transactionIdBytes, govActionIndexNumber] = fromA + // Convert CBOR types to domain types + const transactionId = yield* ParseResult.decode(TransactionHash.FromBytes)(transactionIdBytes) + const govActionIndex = yield* ParseResult.decode(TransactionIndex.TransactionIndex)(Number(govActionIndexNumber)) + return new GovActionId({ transactionId, govActionIndex }) + }) +}) + +/** + * Parameter change governance action schema. + * According to Conway CDDL: parameter_change_action = + * (0, gov_action_id/ nil, protocol_param_update, policy_hash/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export class ParameterChangeAction extends Schema.TaggedClass()("ParameterChangeAction", { + govActionId: Schema.NullOr(GovActionId), // gov_action_id / nil + protocolParamUpdate: CBOR.RecordSchema, // protocol_param_update as CBOR record + policyHash: Schema.NullOr(ScriptHash.ScriptHash) // policy_hash / nil +}) {} + +/** + * CDDL schema for ParameterChangeAction tuple structure. + * Maps to: (0, gov_action_id/ nil, protocol_param_update, policy_hash/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export const ParameterChangeActionCDDL = Schema.Tuple( + Schema.Literal(0n), // action type + Schema.NullOr(GovActionIdCDDL), // gov_action_id / nil + CBOR.RecordSchema, // protocol_param_update + Schema.NullOr(CBOR.ByteArray) // policy_hash / nil +) + +/** + * CDDL transformation schema for ParameterChangeAction. + * + * @since 2.0.0 + * @category schemas + */ +export const ParameterChangeActionFromCDDL = Schema.transformOrFail( + ParameterChangeActionCDDL, + Schema.typeSchema(ParameterChangeAction), + { + strict: true, + encode: (action) => + Eff.gen(function* () { + const govActionId = action.govActionId + ? yield* ParseResult.encode(GovActionIdFromCDDL)(action.govActionId) + : null + const protocolParamUpdate = yield* ParseResult.encode(CBOR.RecordSchema)(action.protocolParamUpdate) + const policyHash = action.policyHash ? yield* ParseResult.encode(ScriptHash.FromBytes)(action.policyHash) : null + + // Return as CBOR tuple + return [0n, govActionId, protocolParamUpdate, policyHash] as const + }), + decode: (cddl) => + Eff.gen(function* () { + const [, govActionIdCDDL, protocolParamUpdate, policyHash] = cddl + const govActionId = govActionIdCDDL ? yield* ParseResult.decode(GovActionIdFromCDDL)(govActionIdCDDL) : null + const policyHashValue = policyHash ? yield* ParseResult.decode(ScriptHash.FromBytes)(policyHash) : null + + return new ParameterChangeAction({ + govActionId, + protocolParamUpdate, + policyHash: policyHashValue + }) + }) + } +) + +/** + * Hard fork initiation governance action schema. + * According to Conway CDDL: hard_fork_initiation_action = + * (1, gov_action_id/ nil, protocol_version, policy_hash/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export class HardForkInitiationAction extends Schema.TaggedClass()( + "HardForkInitiationAction", + { + govActionId: Schema.NullOr(GovActionId), // gov_action_id / nil + protocolVersion: Schema.Tuple(Schema.Number, Schema.Number), // protocol_version = [major, minor] + policyHash: Schema.NullOr(ScriptHash.ScriptHash) // policy_hash / nil + } +) {} + +/** + * CDDL schema for HardForkInitiationAction tuple structure. + * Maps to: (1, gov_action_id/ nil, protocol_version, policy_hash/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export const HardForkInitiationActionCDDL = Schema.Tuple( + Schema.Literal(1n), // action type + Schema.NullOr(GovActionIdCDDL), // gov_action_id / nil + Schema.Tuple(CBOR.Integer, CBOR.Integer), // protocol_version = [major, minor] + Schema.NullOr(CBOR.ByteArray) // policy_hash / nil +) + +/** + * CDDL transformation schema for HardForkInitiationAction. + * + * @since 2.0.0 + * @category schemas + */ +export const HardForkInitiationActionFromCDDL = Schema.transformOrFail( + HardForkInitiationActionCDDL, + Schema.typeSchema(HardForkInitiationAction), + { + strict: true, + encode: (action) => + Eff.gen(function* () { + const govActionId = action.govActionId + ? yield* ParseResult.encode(GovActionIdFromCDDL)(action.govActionId) + : null + const policyHash = action.policyHash ? yield* ParseResult.encode(ScriptHash.FromBytes)(action.policyHash) : null + + // Return as CBOR tuple + return [ + 1n, + govActionId, + [BigInt(action.protocolVersion[0]), BigInt(action.protocolVersion[1])], + policyHash + ] as const + }), + decode: (cddl) => + Eff.gen(function* () { + const [, govActionIdCDDL, protocolVersion, policyHash] = cddl + const govActionId = govActionIdCDDL ? yield* ParseResult.decode(GovActionIdFromCDDL)(govActionIdCDDL) : null + const policyHashValue = policyHash ? yield* ParseResult.decode(ScriptHash.FromBytes)(policyHash) : null + + return new HardForkInitiationAction({ + govActionId, + protocolVersion: [Number(protocolVersion[0]), Number(protocolVersion[1])], + policyHash: policyHashValue + }) + }) + } +) + +/** + * Treasury withdrawals governance action schema. + * According to Conway CDDL: treasury_withdrawals_action = + * (2, { * reward_account => coin }, policy_hash/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export class TreasuryWithdrawalsAction extends Schema.TaggedClass()( + "TreasuryWithdrawalsAction", + { + withdrawals: Schema.MapFromSelf({ + key: RewardAccount.RewardAccount, + value: Coin.Coin + }), + policyHash: Schema.NullOr(ScriptHash.ScriptHash) // policy_hash / nil + } +) {} + +/** + * CDDL schema for TreasuryWithdrawalsAction tuple structure. + * Maps to: (2, { * reward_account => coin }, policy_hash/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export const TreasuryWithdrawalsActionCDDL = Schema.Tuple( + Schema.Literal(2n), // action type + Schema.MapFromSelf({ + key: CBOR.ByteArray, // reward_account as bytes + value: CBOR.Integer // coin as bigint + }), + Schema.NullOr(CBOR.ByteArray) // policy_hash / nil +) + +/** + * CDDL transformation schema for TreasuryWithdrawalsAction. + * + * @since 2.0.0 + * @category schemas + */ +export const TreasuryWithdrawalsActionFromCDDL = Schema.transformOrFail( + TreasuryWithdrawalsActionCDDL, + Schema.typeSchema(TreasuryWithdrawalsAction), + { + strict: true, + encode: (action) => + Eff.gen(function* () { + const withdrawals = new Map() + for (const [rewardAccount, coin] of action.withdrawals) { + const rewardAccountBytes = yield* ParseResult.encode(RewardAccount.FromBytes)(rewardAccount) + withdrawals.set(rewardAccountBytes, coin) + } + const policyHash = action.policyHash ? yield* ParseResult.encode(ScriptHash.FromBytes)(action.policyHash) : null + + // Return as CBOR tuple + return [2n, withdrawals, policyHash] as const + }), + decode: (cddl) => + Eff.gen(function* () { + const [, withdrawals, policyHash] = cddl + const policyHashValue = policyHash ? yield* ParseResult.decode(ScriptHash.FromBytes)(policyHash) : null + const withdrawalsMap = new Map() + for (const [rewardAccountBytes, coin] of withdrawals) { + const rewardAccount = yield* ParseResult.decode(RewardAccount.FromBytes)(rewardAccountBytes) + withdrawalsMap.set(rewardAccount, coin) + } + + return new TreasuryWithdrawalsAction({ + withdrawals: withdrawalsMap, + policyHash: policyHashValue + }) + }) + } +) + +/** + * No confidence governance action schema. + * According to Conway CDDL: no_confidence = + * (3, gov_action_id/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export class NoConfidenceAction extends Schema.TaggedClass()("NoConfidenceAction", { + govActionId: Schema.NullOr(GovActionId) // gov_action_id / nil +}) {} + +/** + * CDDL schema for NoConfidenceAction tuple structure. + * Maps to: (3, gov_action_id/ nil) + * + * @since 2.0.0 + * @category schemas + */ +export const NoConfidenceActionCDDL = Schema.Tuple( + Schema.Literal(3n), // action type + Schema.NullOr(GovActionIdCDDL) // gov_action_id / nil +) + +/** + * CDDL transformation schema for NoConfidenceAction. + * + * @since 2.0.0 + * @category schemas + */ +export const NoConfidenceActionFromCDDL = Schema.transformOrFail( + NoConfidenceActionCDDL, + Schema.typeSchema(NoConfidenceAction), + { + strict: true, + encode: (action) => + Eff.gen(function* () { + const govActionId = action.govActionId + ? yield* ParseResult.encode(GovActionIdFromCDDL)(action.govActionId) + : null + + // Return as CBOR tuple + return [3n, govActionId] as const + }), + decode: (cddl) => + Eff.gen(function* () { + const [, govActionIdCDDL] = cddl + const govActionId = govActionIdCDDL ? yield* ParseResult.decode(GovActionIdFromCDDL)(govActionIdCDDL) : null + + return new NoConfidenceAction({ + govActionId + }) + }) + } +) + +/** + * Update committee governance action schema. + * According to Conway CDDL: update_committee = + * (4, gov_action_id/ nil, set, { * committee_cold_credential => committee_hot_credential }, unit_interval) + * + * @since 2.0.0 + * @category schemas + */ +export class UpdateCommitteeAction extends Schema.TaggedClass()("UpdateCommitteeAction", { + govActionId: Schema.NullOr(GovActionId), // gov_action_id / nil + membersToRemove: Schema.Array(CBOR.CBORSchema), // set + membersToAdd: CBOR.MapSchema, // { * committee_cold_credential => committee_hot_credential } + threshold: CBOR.CBORSchema // unit_interval +}) {} + +/** + * CDDL schema for UpdateCommitteeAction tuple structure. + * Maps to: (4, gov_action_id/ nil, set, { * committee_cold_credential => committee_hot_credential }, unit_interval) + * + * @since 2.0.0 + * @category schemas + */ +export const UpdateCommitteeActionCDDL = Schema.Tuple( + Schema.Literal(4n), // action type + Schema.NullOr(GovActionIdCDDL), // gov_action_id / nil + Schema.Array(CBOR.CBORSchema), // set + CBOR.MapSchema, // { * committee_cold_credential => committee_hot_credential } + CBOR.CBORSchema // unit_interval +) + +/** + * CDDL transformation schema for UpdateCommitteeAction. + * + * @since 2.0.0 + * @category schemas + */ +export const UpdateCommitteeActionFromCDDL = Schema.transformOrFail( + UpdateCommitteeActionCDDL, + Schema.typeSchema(UpdateCommitteeAction), + { + strict: true, + encode: (action) => + Eff.gen(function* () { + const govActionId = action.govActionId + ? yield* ParseResult.encode(GovActionIdFromCDDL)(action.govActionId) + : null + const membersToRemove = yield* ParseResult.encode(Schema.Array(CBOR.CBORSchema))(action.membersToRemove) + const membersToAdd = yield* ParseResult.encode(CBOR.MapSchema)(action.membersToAdd) + const threshold = yield* ParseResult.encode(CBOR.CBORSchema)(action.threshold) + + // Return as CBOR tuple + return [4n, govActionId, membersToRemove, membersToAdd, threshold] as const + }), + decode: (cddl) => + Eff.gen(function* () { + const [, govActionIdCDDL, membersToRemove, membersToAdd, threshold] = cddl + const govActionId = govActionIdCDDL ? yield* ParseResult.decode(GovActionIdFromCDDL)(govActionIdCDDL) : null + + return new UpdateCommitteeAction({ + govActionId, + membersToRemove, + membersToAdd, + threshold + }) + }) + } +) + +/** + * New constitution governance action schema. + * According to Conway CDDL: new_constitution = + * (5, gov_action_id/ nil, constitution) + * + * @since 2.0.0 + * @category schemas + */ +export class NewConstitutionAction extends Schema.TaggedClass()("NewConstitutionAction", { + govActionId: Schema.NullOr(GovActionId), // gov_action_id / nil + constitution: CBOR.CBORSchema // constitution as CBOR +}) {} + +/** + * CDDL schema for NewConstitutionAction tuple structure. + * Maps to: (5, gov_action_id/ nil, constitution) + * + * @since 2.0.0 + * @category schemas + */ +export const NewConstitutionActionCDDL = Schema.Tuple( + Schema.Literal(5n), // action type + Schema.NullOr(GovActionIdCDDL), // gov_action_id / nil + CBOR.CBORSchema // constitution +) + +/** + * CDDL transformation schema for NewConstitutionAction. + * + * @since 2.0.0 + * @category schemas + */ +export const NewConstitutionActionFromCDDL = Schema.transformOrFail( + NewConstitutionActionCDDL, + Schema.typeSchema(NewConstitutionAction), + { + strict: true, + encode: (action) => + Eff.gen(function* () { + const govActionId = action.govActionId + ? yield* ParseResult.encode(GovActionIdFromCDDL)(action.govActionId) + : null + const constitution = yield* ParseResult.encode(CBOR.CBORSchema)(action.constitution) + + // Return as CBOR tuple + return [5n, govActionId, constitution] as const + }), + decode: (cddl) => + Eff.gen(function* () { + const [, govActionIdCDDL, constitution] = cddl + const govActionId = govActionIdCDDL ? yield* ParseResult.decode(GovActionIdFromCDDL)(govActionIdCDDL) : null + + return new NewConstitutionAction({ + govActionId, + constitution + }) + }) + } +) + +/** + * Info governance action schema. + * According to Conway CDDL: info_action = (6) + * + * @since 2.0.0 + * @category schemas + */ +export class InfoAction extends Schema.TaggedClass()("InfoAction", { + // Info action has no additional data +}) {} + +/** + * CDDL schema for InfoAction tuple structure. + * Maps to: (6) + * + * @since 2.0.0 + * @category schemas + */ +export const InfoActionCDDL = Schema.Tuple( + Schema.Literal(6n) // action type +) + +/** + * CDDL transformation schema for InfoAction. + * + * @since 2.0.0 + * @category schemas + */ +export const InfoActionFromCDDL = Schema.transformOrFail(InfoActionCDDL, Schema.typeSchema(InfoAction), { + strict: true, + encode: (_action) => + Eff.gen(function* () { + // Return as CBOR tuple + return [6n] as const + }), + decode: (_cddl) => + Eff.gen(function* () { + return new InfoAction({}) + }) +}) + +/** + * GovernanceAction union schema based on Conway CDDL specification. + * + * ``` + * governance_action = + * [ 0, parameter_change_action ] + * / [ 1, hard_fork_initiation_action ] + * / [ 2, treasury_withdrawals_action ] + * / [ 3, no_confidence ] + * / [ 4, update_committee ] + * / [ 5, new_constitution ] + * / [ 6, info_action ] + * ``` + * + * @since 2.0.0 + * @category schemas + */ +export const GovernanceAction = Schema.Union( + ParameterChangeAction, + HardForkInitiationAction, + TreasuryWithdrawalsAction, + NoConfidenceAction, + UpdateCommitteeAction, + NewConstitutionAction, + InfoAction +) + +/** + * Type alias for GovernanceAction. + * + * @since 2.0.0 + * @category model + */ +export type GovernanceAction = Schema.Schema.Type + +/** + * CDDL schema for GovernanceAction tuple structure. + * Maps action types to their data according to Conway specification. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = Schema.Union( + ParameterChangeActionCDDL, + HardForkInitiationActionCDDL, + TreasuryWithdrawalsActionCDDL, + NoConfidenceActionCDDL, + UpdateCommitteeActionCDDL, + NewConstitutionActionCDDL, + InfoActionCDDL +) + +/** + * CDDL transformation schema for GovernanceAction. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.Union( + ParameterChangeActionFromCDDL, + HardForkInitiationActionFromCDDL, + TreasuryWithdrawalsActionFromCDDL, + NoConfidenceActionFromCDDL, + UpdateCommitteeActionFromCDDL, + NewConstitutionActionFromCDDL, + InfoActionFromCDDL +) + +/** + * Check if two GovernanceAction instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: GovernanceAction, b: GovernanceAction): boolean => { + if (a._tag !== b._tag) return false + + switch (a._tag) { + case "ParameterChangeAction": + return ( + b._tag === "ParameterChangeAction" && + JSON.stringify(a.protocolParamUpdate) === JSON.stringify(b.protocolParamUpdate) && + JSON.stringify(a.policyHash) === JSON.stringify(b.policyHash) && + JSON.stringify(a.govActionId) === JSON.stringify(b.govActionId) + ) + case "HardForkInitiationAction": + return ( + b._tag === "HardForkInitiationAction" && + JSON.stringify(a.protocolVersion) === JSON.stringify(b.protocolVersion) && + JSON.stringify(a.policyHash) === JSON.stringify(b.policyHash) && + JSON.stringify(a.govActionId) === JSON.stringify(b.govActionId) + ) + case "TreasuryWithdrawalsAction": + return ( + b._tag === "TreasuryWithdrawalsAction" && + JSON.stringify(a.withdrawals) === JSON.stringify(b.withdrawals) && + JSON.stringify(a.policyHash) === JSON.stringify(b.policyHash) + ) + case "NoConfidenceAction": + return b._tag === "NoConfidenceAction" && JSON.stringify(a.govActionId) === JSON.stringify(b.govActionId) + case "UpdateCommitteeAction": + return ( + b._tag === "UpdateCommitteeAction" && + JSON.stringify(a.membersToRemove) === JSON.stringify(b.membersToRemove) && + JSON.stringify(a.membersToAdd) === JSON.stringify(b.membersToAdd) && + JSON.stringify(a.threshold) === JSON.stringify(b.threshold) && + JSON.stringify(a.govActionId) === JSON.stringify(b.govActionId) + ) + case "NewConstitutionAction": + return ( + b._tag === "NewConstitutionAction" && + JSON.stringify(a.constitution) === JSON.stringify(b.constitution) && + JSON.stringify(a.govActionId) === JSON.stringify(b.govActionId) + ) + case "InfoAction": + return b._tag === "InfoAction" + } +} + +/** + * Create a parameter change governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeParameterChange = ( + govActionId: GovActionId | null, + protocolParamUpdate: Record, + policyHash: ScriptHash.ScriptHash | null = null +): ParameterChangeAction => + new ParameterChangeAction({ + govActionId, + protocolParamUpdate, + policyHash + }) + +/** + * Create a hard fork initiation governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeHardForkInitiation = ( + govActionId: GovActionId | null, + protocolVersion: readonly [number, number], + policyHash: ScriptHash.ScriptHash | null = null +): HardForkInitiationAction => + new HardForkInitiationAction({ + govActionId, + protocolVersion, + policyHash + }) + +/** + * Create a treasury withdrawals governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeTreasuryWithdrawals = ( + withdrawals: Map, + policyHash: ScriptHash.ScriptHash | null = null +): TreasuryWithdrawalsAction => + new TreasuryWithdrawalsAction({ + withdrawals, + policyHash + }) + +/** + * Create a no confidence governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeNoConfidence = (govActionId: GovActionId | null): NoConfidenceAction => + new NoConfidenceAction({ + govActionId + }) + +/** + * Create an update committee governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeUpdateCommittee = ( + govActionId: GovActionId | null, + membersToRemove: ReadonlyArray, + membersToAdd: ReadonlyMap, + threshold: CBOR.CBOR +): UpdateCommitteeAction => + new UpdateCommitteeAction({ + govActionId, + membersToRemove, + membersToAdd, + threshold + }) + +/** + * Create a new constitution governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeNewConstitution = (govActionId: GovActionId | null, constitution: CBOR.CBOR): NewConstitutionAction => + new NewConstitutionAction({ + govActionId, + constitution + }) + +/** + * Create an info governance action. + * + * @since 2.0.0 + * @category constructors + */ +export const makeInfo = (): InfoAction => new InfoAction({}) + +/** + * FastCheck arbitrary for GovernanceAction. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.oneof(FastCheck.constant(makeNoConfidence(null)), FastCheck.constant(makeInfo())) + +/** + * Check if a value is a valid GovernanceAction. + * + * @since 2.0.0 + * @category predicates + */ +export const is = Schema.is(GovernanceAction) + +/** + * Type guards for each governance action variant. + * + * @since 2.0.0 + * @category type guards + */ +export const isParameterChangeAction = (action: GovernanceAction): action is ParameterChangeAction => + action._tag === "ParameterChangeAction" + +export const isHardForkInitiationAction = (action: GovernanceAction): action is HardForkInitiationAction => + action._tag === "HardForkInitiationAction" + +export const isTreasuryWithdrawalsAction = (action: GovernanceAction): action is TreasuryWithdrawalsAction => + action._tag === "TreasuryWithdrawalsAction" + +export const isNoConfidenceAction = (action: GovernanceAction): action is NoConfidenceAction => + action._tag === "NoConfidenceAction" + +export const isUpdateCommitteeAction = (action: GovernanceAction): action is UpdateCommitteeAction => + action._tag === "UpdateCommitteeAction" + +export const isNewConstitutionAction = (action: GovernanceAction): action is NewConstitutionAction => + action._tag === "NewConstitutionAction" + +export const isInfoAction = (action: GovernanceAction): action is InfoAction => action._tag === "InfoAction" + +/** + * Pattern matching utility for GovernanceAction. + * + * @since 2.0.0 + * @category pattern matching + */ +export const match = ( + action: GovernanceAction, + patterns: { + ParameterChangeAction: ( + govActionId: GovActionId | null, + protocolParams: Record, + policyHash: ScriptHash.ScriptHash | null + ) => R + HardForkInitiationAction: ( + govActionId: GovActionId | null, + protocolVersion: readonly [number, number], + policyHash: ScriptHash.ScriptHash | null + ) => R + TreasuryWithdrawalsAction: ( + withdrawals: Map, + policyHash: ScriptHash.ScriptHash | null + ) => R + NoConfidenceAction: (govActionId: GovActionId | null) => R + UpdateCommitteeAction: ( + govActionId: GovActionId | null, + membersToRemove: ReadonlyArray, + membersToAdd: ReadonlyMap, + threshold: CBOR.CBOR + ) => R + NewConstitutionAction: (govActionId: GovActionId | null, constitution: CBOR.CBOR) => R + InfoAction: () => R + } +): R => { + switch (action._tag) { + case "ParameterChangeAction": + return patterns.ParameterChangeAction(action.govActionId, action.protocolParamUpdate, action.policyHash) + case "HardForkInitiationAction": + return patterns.HardForkInitiationAction(action.govActionId, action.protocolVersion, action.policyHash) + case "TreasuryWithdrawalsAction": + return patterns.TreasuryWithdrawalsAction(action.withdrawals, action.policyHash) + case "NoConfidenceAction": + return patterns.NoConfidenceAction(action.govActionId) + case "UpdateCommitteeAction": + return patterns.UpdateCommitteeAction( + action.govActionId, + action.membersToRemove, + action.membersToAdd, + action.threshold + ) + case "NewConstitutionAction": + return patterns.NewConstitutionAction(action.govActionId, action.constitution) + case "InfoAction": + return patterns.InfoAction() + } +} diff --git a/packages/evolution/src/Hash28.ts b/packages/evolution/src/Hash28.ts index 87a3cc5a..744d0392 100644 --- a/packages/evolution/src/Hash28.ts +++ b/packages/evolution/src/Hash28.ts @@ -1,7 +1,6 @@ -import { Data, Schema } from "effect" +import { Data, Effect as Eff, Schema } from "effect" import * as Bytes from "./Bytes.js" -import * as _Codec from "./Codec.js" export class Hash28Error extends Data.TaggedError("Hash28Error")<{ message?: string @@ -9,8 +8,8 @@ export class Hash28Error extends Data.TaggedError("Hash28Error")<{ }> {} // Add constants following the style guide -export const HASH28_BYTES_LENGTH = 28 -export const HASH28_HEX_LENGTH = 56 +export const BYTES_LENGTH = 28 +export const HEX_LENGTH = 56 /** * Schema for Hash28 bytes with 28-byte length validation. @@ -18,12 +17,12 @@ export const HASH28_HEX_LENGTH = 56 * @since 2.0.0 * @category schemas */ -export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe( - Schema.filter((a) => a.length === HASH28_BYTES_LENGTH) -).annotations({ +export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe(Schema.filter((a) => a.length === BYTES_LENGTH)).annotations({ identifier: "Hash28.Bytes", - message: (issue) => - `Hash28 bytes must be exactly ${HASH28_BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}` + title: "28-byte Hash Array", + description: "A Uint8Array containing exactly 28 bytes", + message: (issue) => `Hash28 bytes must be exactly ${BYTES_LENGTH} bytes, got ${(issue.actual as Uint8Array).length}`, + examples: [new Uint8Array(28).fill(0)] }) /** @@ -32,13 +31,13 @@ export const BytesSchema = Schema.Uint8ArrayFromSelf.pipe( * @since 2.0.0 * @category schemas */ -export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a: string) => a.length === HASH28_HEX_LENGTH)).annotations( - { - identifier: "Hash28.Hex", - message: (issue) => - `Hash28 hex must be exactly ${HASH28_HEX_LENGTH} characters, got ${(issue.actual as string).length}` - } -) +export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a) => a.length === HEX_LENGTH)).annotations({ + identifier: "Hash28.Hex", + title: "28-byte Hash Hex String", + description: "A hexadecimal string representing exactly 28 bytes (56 characters)", + message: (issue) => `Hash28 hex must be exactly ${HEX_LENGTH} characters, got ${(issue.actual as string).length}`, + examples: ["a".repeat(56)] +}) /** * Schema for variable-length byte arrays from 0 to 28 bytes. @@ -48,10 +47,10 @@ export const HexSchema = Bytes.HexSchema.pipe(Schema.filter((a: string) => a.len * @category schemas */ export const VariableBytesSchema = Schema.Uint8ArrayFromSelf.pipe( - Schema.filter((a) => a.length >= 0 && a.length <= HASH28_BYTES_LENGTH) + Schema.filter((a) => a.length >= 0 && a.length <= BYTES_LENGTH) ).annotations({ message: (issue) => - `must be a byte array of length 0 to ${HASH28_BYTES_LENGTH}, but got ${(issue.actual as Uint8Array).length}`, + `must be a byte array of length 0 to ${BYTES_LENGTH}, but got ${(issue.actual as Uint8Array).length}`, identifier: "Hash28.VariableBytes" }) @@ -63,10 +62,9 @@ export const VariableBytesSchema = Schema.Uint8ArrayFromSelf.pipe( * @category schemas */ export const VariableHexSchema = Bytes.HexSchema.pipe( - Schema.filter((a: string) => a.length >= 0 && a.length <= HASH28_HEX_LENGTH) + Schema.filter((a) => a.length >= 0 && a.length <= HEX_LENGTH) ).annotations({ - message: (issue) => - `must be a hex string of length 0 to ${HASH28_HEX_LENGTH}, but got ${(issue.actual as string).length}`, + message: (issue) => `must be a hex string of length 0 to ${HEX_LENGTH}, but got ${(issue.actual as string).length}`, identifier: "Hash28.VariableHex" }) @@ -93,31 +91,11 @@ export const FromBytes = Schema.transform(BytesSchema, HexSchema, { } return array } -}) - -/** - * Schema transformation that converts from hex string to Uint8Array. - * Like Bytes.FromHex but with Hash28-specific length validation. - * - * @since 2.0.0 - * @category schemas - */ -export const FromHex = Schema.transform(HexSchema, BytesSchema, { - strict: true, - decode: (fromA) => { - const array = new Uint8Array(fromA.length / 2) - for (let ai = 0, hi = 0; ai < array.length; ai++, hi += 2) { - array[ai] = parseInt(fromA.slice(hi, hi + 2), 16) - } - return array - }, - encode: (toA) => { - let hex = "" - for (let i = 0; i < toA.length; i++) { - hex += toA[i].toString(16).padStart(2, "0") - } - return hex - } +}).annotations({ + identifier: "Hash28.FromBytes", + title: "Hash28 from Uint8Array", + description: "Transforms a 28-byte Uint8Array to hex string representation", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) /** @@ -144,18 +122,99 @@ export const FromVariableBytes = Schema.transform(VariableBytesSchema, VariableH } return array } +}).annotations({ + identifier: "Hash28.FromVariableBytes", + title: "Variable Hash28 from Uint8Array", + description: "Transforms variable-length byte arrays (0-28 bytes) to hex strings (0-56 chars)", + documentation: "Converts raw bytes to lowercase hexadecimal string without 0x prefix" }) /** - * Codec for Hash28 encoding and decoding operations. + * Effect namespace containing composable operations that can fail. + * All functions return Effect objects for proper error handling and composition. + */ +export namespace Effect { + /** + * Parse Hash28 from raw bytes using Effect error handling. + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new Hash28Error({ + message: "Failed to parse Hash28 from bytes", + cause + }) + ) + + /** + * Convert Hash28 hex to raw bytes using Effect error handling. + */ + export const toBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(hex), + (cause) => + new Hash28Error({ + message: "Failed to encode Hash28 to bytes", + cause + }) + ) + + /** + * Parse variable-length data from raw bytes using Effect error handling. + */ + export const fromVariableBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromVariableBytes)(bytes), + (cause) => + new Hash28Error({ + message: "Failed to parse variable Hash28 from bytes", + cause + }) + ) + + /** + * Convert variable-length hex to raw bytes using Effect error handling. + */ + export const toVariableBytes = (hex: string): Eff.Effect => + Eff.mapError( + Schema.encode(FromVariableBytes)(hex), + (cause) => + new Hash28Error({ + message: "Failed to encode variable Hash28 to bytes", + cause + }) + ) +} + +/** + * Parse Hash28 from raw bytes (unsafe - throws on error). * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - variableBytes: FromVariableBytes - }, - Hash28Error -) +export const fromBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert Hash28 hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toBytes(hex)) + +/** + * Parse variable-length data from raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category parsing + */ +export const fromVariableBytes = (bytes: Uint8Array): string => Eff.runSync(Effect.fromVariableBytes(bytes)) + +/** + * Convert variable-length hex to raw bytes (unsafe - throws on error). + * + * @since 2.0.0 + * @category encoding + */ +export const toVariableBytes = (hex: string): Uint8Array => Eff.runSync(Effect.toVariableBytes(hex)) diff --git a/packages/evolution/src/Header.ts b/packages/evolution/src/Header.ts index c2c4dc33..ac5ab2b5 100644 --- a/packages/evolution/src/Header.ts +++ b/packages/evolution/src/Header.ts @@ -94,7 +94,7 @@ export const FromCDDL = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → Header @@ -106,13 +106,13 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array FromBytes(options) // Uint8Array → Header ) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const Codec = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => createEncoders( { cborBytes: FromBytes(options), diff --git a/packages/evolution/src/HeaderBody.ts b/packages/evolution/src/HeaderBody.ts index 412ceb57..82cf9f68 100644 --- a/packages/evolution/src/HeaderBody.ts +++ b/packages/evolution/src/HeaderBody.ts @@ -1,13 +1,13 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as BlockBodyHash from "./BlockBodyHash.js" import * as BlockHeaderHash from "./BlockHeaderHash.js" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as Ed25519Signature from "./Ed25519Signature.js" import * as KESVkey from "./KESVkey.js" import * as Natural from "./Natural.js" +import * as Numeric from "./Numeric.js" import * as OperationalCert from "./OperationalCert.js" import * as ProtocolVersion from "./ProtocolVersion.js" import * as VKey from "./VKey.js" @@ -56,6 +56,37 @@ export class HeaderBody extends Schema.TaggedClass()("HeaderBody", { protocolVersion: ProtocolVersion.ProtocolVersion }) {} +/** + * Smart constructor for creating HeaderBody instances + * + * @since 2.0.0 + * @category constructors + */ +export const make = (props: { + blockNumber: number + slot: number + prevHash: BlockHeaderHash.BlockHeaderHash | null + issuerVkey: VKey.VKey + vrfVkey: VrfVkey.VrfVkey + vrfResult: VrfCert.VrfCert + blockBodySize: number + blockBodyHash: BlockBodyHash.BlockBodyHash + operationalCert: OperationalCert.OperationalCert + protocolVersion: ProtocolVersion.ProtocolVersion +}): HeaderBody => + new HeaderBody({ + blockNumber: Natural.make(props.blockNumber), + slot: Natural.make(props.slot), + prevHash: props.prevHash, + issuerVkey: props.issuerVkey, + vrfVkey: props.vrfVkey, + vrfResult: props.vrfResult, + blockBodySize: Natural.make(props.blockBodySize), + blockBodyHash: props.blockBodyHash, + operationalCert: props.operationalCert, + protocolVersion: props.protocolVersion + }) + /** * Check if two HeaderBody instances are equal. * @@ -74,6 +105,45 @@ export const equals = (a: HeaderBody, b: HeaderBody): boolean => OperationalCert.equals(a.operationalCert, b.operationalCert) && ProtocolVersion.equals(a.protocolVersion, b.protocolVersion) +/** + * FastCheck arbitrary for generating random HeaderBody instances + * + * @since 2.0.0 + * @category testing + */ +export const arbitrary = FastCheck.record({ + blockNumber: Natural.arbitrary, + slot: Natural.arbitrary, + prevHash: FastCheck.option(BlockHeaderHash.arbitrary), + issuerVkey: VKey.arbitrary, + vrfVkey: VrfVkey.arbitrary, + vrfResult: FastCheck.record({ + output: FastCheck.string(), + proof: FastCheck.string() + }), + blockBodySize: Natural.arbitrary, + blockBodyHash: BlockBodyHash.arbitrary, + operationalCert: OperationalCert.arbitrary, + protocolVersion: ProtocolVersion.arbitrary +}).map( + (props) => + new HeaderBody({ + blockNumber: props.blockNumber, + slot: props.slot, + prevHash: props.prevHash, + issuerVkey: props.issuerVkey, + vrfVkey: props.vrfVkey, + vrfResult: new VrfCert.VrfCert({ + output: props.vrfResult.output as VrfCert.VRFOutput, + proof: props.vrfResult.proof as VrfCert.VRFProof + }), + blockBodySize: props.blockBodySize, + blockBodyHash: props.blockBodyHash, + operationalCert: props.operationalCert, + protocolVersion: props.protocolVersion + }) +) + /** * CDDL schema for HeaderBody. * header_body = [ @@ -115,7 +185,7 @@ export const FromCDDL = Schema.transformOrFail( { strict: true, encode: (toA) => - Effect.gen(function* () { + Eff.gen(function* () { const prevHashBytes = toA.prevHash ? yield* ParseResult.encode(BlockHeaderHash.FromBytes)(toA.prevHash) : null const issuerVkeyBytes = yield* ParseResult.encode(VKey.FromBytes)(toA.issuerVkey) const vrfVkeyBytes = yield* ParseResult.encode(VrfVkey.FromBytes)(toA.vrfVkey) @@ -155,7 +225,7 @@ export const FromCDDL = Schema.transformOrFail( [hotVkeyBytes, sequenceNumber, kesPeriod, sigmaBytes], [protocolMajor, protocolMinor] ]) => - Effect.gen(function* () { + Eff.gen(function* () { const prevHash = prevHashBytes ? yield* ParseResult.decode(BlockHeaderHash.FromBytes)(prevHashBytes) : null const issuerVkey = yield* ParseResult.decode(VKey.FromBytes)(issuerVkeyBytes) const vrfVkey = yield* ParseResult.decode(VrfVkey.FromBytes)(vrfVkeyBytes) @@ -167,8 +237,8 @@ export const FromCDDL = Schema.transformOrFail( return yield* ParseResult.decode(HeaderBody)({ _tag: "HeaderBody", - blockNumber: Natural.Natural.make(Number(blockNumber)), - slot: Natural.Natural.make(Number(slot)), + blockNumber: Natural.make(Number(blockNumber)), + slot: Natural.make(Number(slot)), prevHash, issuerVkey, vrfVkey, @@ -176,22 +246,25 @@ export const FromCDDL = Schema.transformOrFail( output: vrfOutput, proof: vrfProof }), - blockBodySize: Natural.Natural.make(Number(blockBodySize)), + blockBodySize: Natural.make(Number(blockBodySize)), blockBodyHash, operationalCert: new OperationalCert.OperationalCert({ hotVkey, - sequenceNumber, - kesPeriod, + sequenceNumber: Numeric.Uint64Make(sequenceNumber), + kesPeriod: Numeric.Uint64Make(kesPeriod), sigma }), - protocolVersion: new ProtocolVersion.ProtocolVersion({ + protocolVersion: ProtocolVersion.make({ major: Number(protocolMajor), minor: Number(protocolMinor) }) }) }) } -) +).annotations({ + identifier: "HeaderBody.FromCDDL", + description: "Transforms CBOR structure to HeaderBody" +}) /** * Check if the given value is a valid HeaderBody. @@ -207,11 +280,14 @@ export const isHeaderBody = Schema.is(HeaderBody) * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → HeaderBody - ) + ).annotations({ + identifier: "HeaderBody.FromCBORBytes", + description: "Transforms CBOR bytes to HeaderBody" + }) /** * CBOR hex transformation schema for HeaderBody. @@ -219,17 +295,103 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → HeaderBody - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - HeaderBodyError - ) + FromCBORBytes(options) // Uint8Array → HeaderBody + ).annotations({ + identifier: "HeaderBody.FromCBORHex", + description: "Transforms CBOR hex string to HeaderBody" + }) + +/** + * Effect namespace for HeaderBody operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert CBOR bytes to HeaderBody using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (cause) => new HeaderBodyError({ message: "Failed to decode from CBOR bytes", cause }) + ) + + /** + * Convert CBOR hex string to HeaderBody using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (cause) => new HeaderBodyError({ message: "Failed to decode from CBOR hex", cause }) + ) + + /** + * Convert HeaderBody to CBOR bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORBytes = (headerBody: HeaderBody, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(headerBody), + (cause) => new HeaderBodyError({ message: "Failed to encode to CBOR bytes", cause }) + ) + + /** + * Convert HeaderBody to CBOR hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORHex = (headerBody: HeaderBody, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(headerBody), + (cause) => new HeaderBodyError({ message: "Failed to encode to CBOR hex", cause }) + ) +} + +/** + * Convert CBOR bytes to HeaderBody (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): HeaderBody => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Convert CBOR hex string to HeaderBody (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): HeaderBody => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Convert HeaderBody to CBOR bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORBytes = (headerBody: HeaderBody, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(headerBody, options)) + +/** + * Convert HeaderBody to CBOR hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORHex = (headerBody: HeaderBody, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(headerBody, options)) diff --git a/packages/evolution/src/IPv4.ts b/packages/evolution/src/IPv4.ts index 72662694..f7863466 100644 --- a/packages/evolution/src/IPv4.ts +++ b/packages/evolution/src/IPv4.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Either as E, FastCheck, Schema } from "effect" import * as Bytes4 from "./Bytes4.js" -import { createEncoders } from "./Codec.js" /** * Error class for IPv4 related operations. @@ -21,7 +20,7 @@ export class IPv4Error extends Data.TaggedError("IPv4Error")<{ * @since 2.0.0 * @category schemas */ -export const IPv4 = pipe(Bytes4.HexSchema, Schema.brand("IPv4")).annotations({ +export const IPv4 = Bytes4.HexSchema.pipe(Schema.brand("IPv4")).annotations({ identifier: "IPv4" }) @@ -50,26 +49,168 @@ export const FromHex = Schema.compose( export const equals = (a: IPv4, b: IPv4): boolean => a === b /** - * Generate a random IPv4. + * Check if the given value is a valid IPv4 * * @since 2.0.0 - * @category generators + * @category predicates */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes4.BYTES_LENGTH, - maxLength: Bytes4.BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const isIPv4 = Schema.is(IPv4) /** - * Codec utilities for IPv4 encoding and decoding operations. + * FastCheck arbitrary for generating random IPv4 instances. * * @since 2.0.0 - * @category encoding/decoding + * @category arbitrary */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - IPv4Error -) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes4.HEX_LENGTH, + maxLength: Bytes4.HEX_LENGTH +}).map((hex) => hex as IPv4) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse IPv4 from bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): IPv4 => { + try { + return Schema.decodeSync(FromBytes)(bytes) + } catch (cause) { + throw new IPv4Error({ + message: "Failed to parse IPv4 from bytes", + cause + }) + } +} + +/** + * Parse IPv4 from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): IPv4 => { + try { + return Schema.decodeSync(FromHex)(hex) + } catch (cause) { + throw new IPv4Error({ + message: "Failed to parse IPv4 from hex", + cause + }) + } +} + +/** + * Encode IPv4 to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (ipv4: IPv4): Uint8Array => { + try { + return Schema.encodeSync(FromBytes)(ipv4) + } catch (cause) { + throw new IPv4Error({ + message: "Failed to encode IPv4 to bytes", + cause + }) + } +} + +/** + * Encode IPv4 to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (ipv4: IPv4): string => { + try { + return Schema.encodeSync(FromHex)(ipv4) + } catch (cause) { + throw new IPv4Error({ + message: "Failed to encode IPv4 to hex", + cause + }) + } +} + +// ============================================================================ +// Either Namespace +// ============================================================================ + +/** + * Either-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category either + */ +export namespace Either { + /** + * Parse IPv4 from bytes with Either error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): E.Either => + E.mapLeft( + Schema.decodeEither(FromBytes)(bytes), + (cause) => + new IPv4Error({ + message: "Failed to parse IPv4 from bytes", + cause + }) + ) + + /** + * Parse IPv4 from hex string with Either error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): E.Either => + E.mapLeft( + Schema.decodeEither(FromHex)(hex), + (cause) => + new IPv4Error({ + message: "Failed to parse IPv4 from hex", + cause + }) + ) + + /** + * Encode IPv4 to bytes with Either error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (ipv4: IPv4): E.Either => + E.mapLeft( + Schema.encodeEither(FromBytes)(ipv4), + (cause) => + new IPv4Error({ + message: "Failed to encode IPv4 to bytes", + cause + }) + ) + + /** + * Encode IPv4 to hex string with Either error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (ipv4: IPv4): E.Either => + E.mapLeft( + Schema.encodeEither(FromHex)(ipv4), + (cause) => + new IPv4Error({ + message: "Failed to encode IPv4 to hex", + cause + }) + ) +} diff --git a/packages/evolution/src/IPv6.ts b/packages/evolution/src/IPv6.ts index 7b97f188..96b922fe 100644 --- a/packages/evolution/src/IPv6.ts +++ b/packages/evolution/src/IPv6.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes16 from "./Bytes16.js" -import { createEncoders } from "./Codec.js" /** * Error class for IPv6 related operations. @@ -21,7 +20,7 @@ export class IPv6Error extends Data.TaggedError("IPv6Error")<{ * @since 2.0.0 * @category schemas */ -export const IPv6 = pipe(Bytes16.HexSchema, Schema.brand("IPv6")).annotations({ +export const IPv6 = Bytes16.HexSchema.pipe(Schema.brand("IPv6")).annotations({ identifier: "IPv6" }) @@ -50,26 +49,136 @@ export const FromHex = Schema.compose( export const equals = (a: IPv6, b: IPv6): boolean => a === b /** - * Generate a random IPv6. + * Check if the given value is a valid IPv6 * * @since 2.0.0 - * @category generators + * @category predicates */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes16.BYTES_LENGTH, - maxLength: Bytes16.BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const isIPv6 = Schema.is(IPv6) /** - * Codec utilities for IPv6 encoding and decoding operations. + * FastCheck arbitrary for generating random IPv6 instances. * * @since 2.0.0 - * @category encoding/decoding + * @category arbitrary */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - IPv6Error -) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes16.HEX_LENGTH, + maxLength: Bytes16.HEX_LENGTH +}).map((hex) => hex as IPv6) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse IPv6 from bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): IPv6 => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse IPv6 from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): IPv6 => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode IPv6 to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (ipv6: IPv6): Uint8Array => Eff.runSync(Effect.toBytes(ipv6)) + +/** + * Encode IPv6 to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (ipv6: IPv6): string => Eff.runSync(Effect.toHex(ipv6)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse IPv6 from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new IPv6Error({ + message: "Failed to parse IPv6 from bytes", + cause + }) + ) + ) + + /** + * Parse IPv6 from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new IPv6Error({ + message: "Failed to parse IPv6 from hex", + cause + }) + ) + ) + + /** + * Encode IPv6 to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (ipv6: IPv6): Eff.Effect => + Schema.encode(FromBytes)(ipv6).pipe( + Eff.mapError( + (cause) => + new IPv6Error({ + message: "Failed to encode IPv6 to bytes", + cause + }) + ) + ) + + /** + * Encode IPv6 to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (ipv6: IPv6): Eff.Effect => + Schema.encode(FromHex)(ipv6).pipe( + Eff.mapError( + (cause) => + new IPv6Error({ + message: "Failed to encode IPv6 to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/KESVkey.ts b/packages/evolution/src/KESVkey.ts index d457799a..df857474 100644 --- a/packages/evolution/src/KESVkey.ts +++ b/packages/evolution/src/KESVkey.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes32 from "./Bytes32.js" -import { createEncoders } from "./Codec.js" /** * Error class for KESVkey related operations. @@ -22,7 +21,7 @@ export class KESVkeyError extends Data.TaggedError("KESVkeyError")<{ * @since 2.0.0 * @category schemas */ -export const KESVkey = pipe(Bytes32.HexSchema, Schema.brand("KESVkey")).annotations({ +export const KESVkey = Bytes32.HexSchema.pipe(Schema.brand("KESVkey")).annotations({ identifier: "KESVkey" }) @@ -51,26 +50,136 @@ export const FromHex = Schema.compose( export const equals = (a: KESVkey, b: KESVkey): boolean => a === b /** - * Generate a random KESVkey. + * Check if the given value is a valid KESVkey * * @since 2.0.0 - * @category generators + * @category predicates */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes32.Bytes32_BYTES_LENGTH, - maxLength: Bytes32.Bytes32_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const isKESVkey = Schema.is(KESVkey) /** - * Codec utilities for KESVkey encoding and decoding operations. + * FastCheck arbitrary for generating random KESVkey instances. * * @since 2.0.0 - * @category encoding/decoding + * @category arbitrary */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - KESVkeyError -) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes32.HEX_LENGTH, + maxLength: Bytes32.HEX_LENGTH +}).map((hex) => hex as KESVkey) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse KESVkey from bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): KESVkey => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse KESVkey from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): KESVkey => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode KESVkey to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (kesVkey: KESVkey): Uint8Array => Eff.runSync(Effect.toBytes(kesVkey)) + +/** + * Encode KESVkey to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (kesVkey: KESVkey): string => Eff.runSync(Effect.toHex(kesVkey)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse KESVkey from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new KESVkeyError({ + message: "Failed to parse KESVkey from bytes", + cause + }) + ) + ) + + /** + * Parse KESVkey from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new KESVkeyError({ + message: "Failed to parse KESVkey from hex", + cause + }) + ) + ) + + /** + * Encode KESVkey to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (kesVkey: KESVkey): Eff.Effect => + Schema.encode(FromBytes)(kesVkey).pipe( + Eff.mapError( + (cause) => + new KESVkeyError({ + message: "Failed to encode KESVkey to bytes", + cause + }) + ) + ) + + /** + * Encode KESVkey to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (kesVkey: KESVkey): Eff.Effect => + Schema.encode(FromHex)(kesVkey).pipe( + Eff.mapError( + (cause) => + new KESVkeyError({ + message: "Failed to encode KESVkey to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/KesSignature.ts b/packages/evolution/src/KesSignature.ts index 3ae22f51..30b574e4 100644 --- a/packages/evolution/src/KesSignature.ts +++ b/packages/evolution/src/KesSignature.ts @@ -1,7 +1,6 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes448 from "./Bytes448.js" -import { createEncoders } from "./Codec.js" /** * Error class for KesSignature related operations. @@ -22,7 +21,7 @@ export class KesSignatureError extends Data.TaggedError("KesSignatureError")<{ * @since 2.0.0 * @category schemas */ -export const KesSignature = pipe(Bytes448.HexSchema, Schema.brand("KesSignature")).annotations({ +export const KesSignature = Bytes448.HexSchema.pipe(Schema.brand("KesSignature")).annotations({ identifier: "KesSignature" }) @@ -51,26 +50,136 @@ export const FromHex = Schema.compose( export const equals = (a: KesSignature, b: KesSignature): boolean => a === b /** - * Generate a random KesSignature. + * Check if the given value is a valid KesSignature * * @since 2.0.0 - * @category generators + * @category predicates */ -export const generator = FastCheck.uint8Array({ - minLength: Bytes448.BYTES_LENGTH, - maxLength: Bytes448.BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const isKesSignature = Schema.is(KesSignature) /** - * Codec utilities for KesSignature encoding and decoding operations. + * FastCheck arbitrary for generating random KesSignature instances. * * @since 2.0.0 - * @category encoding/decoding + * @category arbitrary */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - KesSignatureError -) +export const arbitrary = FastCheck.hexaString({ + minLength: Bytes448.HEX_LENGTH, + maxLength: Bytes448.HEX_LENGTH +}).map((hex) => hex as KesSignature) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse KesSignature from bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): KesSignature => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse KesSignature from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): KesSignature => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode KesSignature to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (kesSignature: KesSignature): Uint8Array => Eff.runSync(Effect.toBytes(kesSignature)) + +/** + * Encode KesSignature to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (kesSignature: KesSignature): string => Eff.runSync(Effect.toHex(kesSignature)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse KesSignature from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new KesSignatureError({ + message: "Failed to parse KesSignature from bytes", + cause + }) + ) + ) + + /** + * Parse KesSignature from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new KesSignatureError({ + message: "Failed to parse KesSignature from hex", + cause + }) + ) + ) + + /** + * Encode KesSignature to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (kesSignature: KesSignature): Eff.Effect => + Schema.encode(FromBytes)(kesSignature).pipe( + Eff.mapError( + (cause) => + new KesSignatureError({ + message: "Failed to encode KesSignature to bytes", + cause + }) + ) + ) + + /** + * Encode KesSignature to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (kesSignature: KesSignature): Eff.Effect => + Schema.encode(FromHex)(kesSignature).pipe( + Eff.mapError( + (cause) => + new KesSignatureError({ + message: "Failed to encode KesSignature to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/KeyHash.ts b/packages/evolution/src/KeyHash.ts index f525c808..b4de4bfc 100644 --- a/packages/evolution/src/KeyHash.ts +++ b/packages/evolution/src/KeyHash.ts @@ -1,7 +1,10 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { blake2b } from "@noble/hashes/blake2" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" +import sodium from "libsodium-wrappers-sumo" -import { createEncoders } from "./Codec.js" import * as Hash28 from "./Hash28.js" +import * as PrivateKey from "./PrivateKey.js" +import * as VKey from "./VKey.js" /** * Error class for KeyHash related operations. @@ -22,8 +25,10 @@ export class KeyHashError extends Data.TaggedError("KeyHashError")<{ * @since 2.0.0 * @category schemas */ -export const KeyHash = pipe(Hash28.HexSchema, Schema.brand("KeyHash")).annotations({ - identifier: "KeyHash" +export const KeyHash = Hash28.HexSchema.pipe(Schema.brand("KeyHash")).annotations({ + identifier: "KeyHash", + title: "Verification Key Hash", + description: "A 28-byte verification key hash" }) export type KeyHash = typeof KeyHash.Type @@ -32,15 +37,21 @@ export const FromBytes = Schema.compose( Hash28.FromBytes, // Uint8Array -> hex string KeyHash // hex string -> KeyHash ).annotations({ - identifier: "KeyHash.FromBytes" + identifier: "KeyHash.FromBytes", + title: "KeyHash from Bytes", + description: "Transforms raw bytes (Uint8Array) to KeyHash hex string", + message: () => "Invalid key hash bytes - must be exactly 28 bytes" }) -export const FromHex = Schema.compose( - Hash28.HexSchema, // string -> hex string - KeyHash // hex string -> KeyHash -).annotations({ - identifier: "KeyHash.FromHex" -}) +export const FromHex = KeyHash + +/** + * Smart constructor for KeyHash that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = KeyHash.make /** * Check if two KeyHash instances are equal. @@ -50,27 +61,232 @@ export const FromHex = Schema.compose( */ export const equals = (a: KeyHash, b: KeyHash): boolean => a === b +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse a KeyHash from raw bytes. + * Expects exactly 28 bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): KeyHash => Eff.runSync(Effect.fromBytes(bytes)) + /** - * Generate a random KeyHash. + * Parse a KeyHash from a hex string. + * Expects exactly 56 hex characters (28 bytes). * * @since 2.0.0 - * @category generators + * @category parsing */ -export const generator = FastCheck.uint8Array({ - minLength: Hash28.HASH28_BYTES_LENGTH, - maxLength: Hash28.HASH28_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const fromHex = (hex: string): KeyHash => Eff.runSync(Effect.fromHex(hex)) /** - * Codec utilities for KeyHash encoding and decoding operations. + * FastCheck arbitrary for generating random KeyHash instances. + * Used for property-based testing to generate valid test data. * * @since 2.0.0 - * @category encoding/decoding + * @category testing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - string: FromHex - }, - KeyHashError +export const arbitrary: FastCheck.Arbitrary = FastCheck.uint8Array({ minLength: 28, maxLength: 28 }).map( + fromBytes ) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a KeyHash to raw bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (keyHash: KeyHash): Uint8Array => Eff.runSync(Effect.toBytes(keyHash)) + +/** + * Convert a KeyHash to a hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (keyHash: KeyHash): string => keyHash // Already a hex string + +// ============================================================================ +// Cryptographic Operations +// ============================================================================ + +/** + * Create a KeyHash from a PrivateKey (sync version that throws KeyHashError). + * All errors are normalized to KeyHashError with contextual information. + * + * @since 2.0.0 + * @category cryptography + */ +export const fromPrivateKey = (privateKey: PrivateKey.PrivateKey): KeyHash => + Eff.runSync(Effect.fromPrivateKey(privateKey)) + +/** + * Create a KeyHash from a VKey (sync version that throws KeyHashError). + * All errors are normalized to KeyHashError with contextual information. + * + * @since 2.0.0 + * @category cryptography + */ +export const fromVKey = (vkey: VKey.VKey): KeyHash => Eff.runSync(Effect.fromVKey(vkey)) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse a KeyHash from raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new KeyHashError({ + message: "Failed to parse KeyHash from bytes", + cause + }) + ) + ) + + /** + * Parse a KeyHash from a hex string using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new KeyHashError({ + message: "Failed to parse KeyHash from hex", + cause + }) + ) + ) + + /** + * Convert a KeyHash to raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (keyHash: KeyHash): Eff.Effect => + Schema.encode(FromBytes)(keyHash).pipe( + Eff.mapError( + (cause) => + new KeyHashError({ + message: "Failed to encode KeyHash to bytes", + cause + }) + ) + ) + + /** + * Create a KeyHash from a PrivateKey using Effect error handling. + * + * @since 2.0.0 + * @category cryptography + */ + export const fromPrivateKey = (privateKey: PrivateKey.PrivateKey): Eff.Effect => + Eff.gen(function* () { + const privateKeyBytes = yield* Eff.mapError( + Schema.encode(PrivateKey.FromBytes)(privateKey), + (cause) => + new KeyHashError({ + message: "Failed to encode private key to bytes", + cause + }) + ) + + const publicKeyBytes = yield* Eff.try({ + try: () => { + if (privateKeyBytes.length === 64) { + // CML-compatible extended private key: use first 32 bytes as scalar + const scalar = privateKeyBytes.slice(0, 32) + return sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + } else { + // Standard 32-byte Ed25519 private key using sodium + return sodium.crypto_sign_seed_keypair(privateKeyBytes).publicKey + } + }, + catch: (cause) => + new KeyHashError({ + message: "Failed to derive public key from private key", + cause + }) + }) + + const keyHashBytes = yield* Eff.try({ + try: () => blake2b(publicKeyBytes, { dkLen: 28 }), + catch: (cause) => + new KeyHashError({ + message: "Failed to hash public key", + cause + }) + }) + + return yield* Eff.mapError( + Schema.decode(FromBytes)(keyHashBytes), + (cause) => + new KeyHashError({ + message: "Failed to create KeyHash from hash bytes", + cause + }) + ) + }) + + /** + * Create a KeyHash from a VKey using Effect error handling. + * + * @since 2.0.0 + * @category cryptography + */ + export const fromVKey = (vkey: VKey.VKey): Eff.Effect => + Eff.gen(function* () { + const publicKeyBytes = yield* Eff.mapError( + Schema.encode(VKey.FromBytes)(vkey), + (cause) => + new KeyHashError({ + message: "Failed to encode VKey to bytes", + cause + }) + ) + + const keyHashBytes = yield* Eff.try({ + try: () => blake2b(publicKeyBytes, { dkLen: 28 }), + catch: (cause) => + new KeyHashError({ + message: "Failed to hash public key", + cause + }) + }) + + return yield* Eff.mapError( + Schema.decode(FromBytes)(keyHashBytes), + (cause) => + new KeyHashError({ + message: "Failed to create KeyHash from hash bytes", + cause + }) + ) + }) +} diff --git a/packages/evolution/src/Metadata.ts b/packages/evolution/src/Metadata.ts new file mode 100644 index 00000000..18cb3680 --- /dev/null +++ b/packages/evolution/src/Metadata.ts @@ -0,0 +1,397 @@ +import { Data, Effect as Eff, Either, FastCheck, ParseResult, Schema } from "effect" + +import * as CBOR from "./CBOR.js" +import * as Numeric from "./Numeric.js" +import * as TransactionMetadatum from "./TransactionMetadatum.js" + +/** + * Error class for Metadata related operations. + * + * @since 2.0.0 + * @category errors + */ +export class MetadataError extends Data.TaggedError("MetadataError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Type representing a transaction metadatum label (uint .size 8). + * + * @since 2.0.0 + * @category model + */ +export type MetadataLabel = typeof MetadataLabel.Type + +/** + * Schema for transaction metadatum label (uint .size 8). + * + * @since 2.0.0 + * @category schemas + */ +export const MetadataLabel = Numeric.Uint8Schema.annotations({ + identifier: "Metadata.MetadataLabel", + description: "A transaction metadatum label (0-255)" +}) + +/** + * Schema for transaction metadata (map from labels to metadata). + * Represents: metadata = {* transaction_metadatum_label => transaction_metadatum} + * + * @since 2.0.0 + * @category schemas + */ +export const Metadata = Schema.MapFromSelf({ + key: MetadataLabel, + value: TransactionMetadatum.TransactionMetadatum +}).annotations({ + identifier: "Metadata", + description: "Transaction metadata as a map from labels to transaction metadata values" +}) + +export type Metadata = typeof Metadata.Type + +/** + * Schema for CDDL-compatible metadata format. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = Schema.MapFromSelf({ + key: Schema.BigIntFromSelf, + value: TransactionMetadatum.CDDLSchema +}) + +/** + * Transform schema from CDDL to Metadata. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Metadata), { + strict: true, + encode: (toI) => + Either.gen(function* () { + const map = new Map() + for (const [label, metadatum] of toI.entries()) { + const transactionMetadatum = yield* ParseResult.encodeEither(TransactionMetadatum.FromCDDL)(metadatum) + map.set(label, transactionMetadatum) + } + return map + }), + decode: (fromA) => + Either.gen(function* () { + const map = new Map() + for (const [label, metadatum] of fromA.entries()) { + const transactionMetadatum = yield* ParseResult.decodeEither(TransactionMetadatum.FromCDDL)(metadatum) + map.set(label, transactionMetadatum) + } + return map + }) +}) + +/** + * Schema transformer for Metadata from CBOR bytes. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose(CBOR.FromBytes(options), FromCDDL).annotations({ + identifier: "Metadata.FromCBORBytes", + description: "Transforms CBOR bytes to Metadata" + }) + +/** + * Schema transformer for Metadata from CBOR hex string. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose(CBOR.FromHex(options), FromCBORBytes(options)).annotations({ + identifier: "Metadata.FromCBORHex", + description: "Transforms CBOR hex string to Metadata" + }) + +// make + +/** + * Smart constructor for Metadata that validates and applies typing. + * + * @since 2.0.0 + * @category constructors + */ +export const make = (map: Map): Metadata => + Schema.decodeSync(Metadata)(map) + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Check if two Metadata instances are equal. + * + * @since 2.0.0 + * @category utilities + */ +export const equals = (a: Metadata, b: Metadata): boolean => { + if (a.size !== b.size) return false + + for (const [key, value] of a.entries()) { + const bValue = b.get(key) + if (!bValue || !TransactionMetadatum.equals(value, bValue)) return false + } + + return true +} + +/** + * FastCheck arbitrary for generating random Metadata instances. + * + * @since 2.0.0 + * @category testing + */ +export const arbitrary: FastCheck.Arbitrary = FastCheck.array( + FastCheck.tuple( + FastCheck.bigInt({ min: 0n, max: 255n }), // MetadataLabel (uint8) + TransactionMetadatum.arbitrary + ), + { maxLength: 5 } +).map((entries) => fromEntries(entries)) + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse Metadata from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Metadata => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse Metadata from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Metadata => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert Metadata to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (metadata: Metadata, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(metadata, options)) + +/** + * Convert Metadata to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (metadata: Metadata, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(metadata, options)) + +// ============================================================================ +// Factory Functions +// ============================================================================ + +/** + * Create Metadata from an array of label-metadatum pairs. + * + * @since 2.0.0 + * @category constructors + */ +export const fromEntries = (entries: Array<[MetadataLabel, TransactionMetadatum.TransactionMetadatum]>): Metadata => + Schema.decodeSync(Metadata)(new Map(entries)) + +/** + * Create an empty Metadata map. + * + * @since 2.0.0 + * @category constructors + */ +export const empty = (): Metadata => new Map() as Metadata + +/** + * Add or update a metadata entry. + * + * @since 2.0.0 + * @category constructors + */ +export const set = ( + metadata: Metadata, + label: MetadataLabel, + metadatum: TransactionMetadatum.TransactionMetadatum +): Metadata => { + const newMap = new Map(metadata) + newMap.set(label, metadatum) + return newMap as Metadata +} + +/** + * Get a metadata entry by label. + * + * @since 2.0.0 + * @category utilities + */ +export const get = (metadata: Metadata, label: MetadataLabel): TransactionMetadatum.TransactionMetadatum | undefined => + metadata.get(label) + +/** + * Check if a label exists in the metadata. + * + * @since 2.0.0 + * @category utilities + */ +export const has = (metadata: Metadata, label: MetadataLabel): boolean => metadata.has(label) + +/** + * Remove a metadata entry by label. + * + * @since 2.0.0 + * @category constructors + */ +export const remove = (metadata: Metadata, label: MetadataLabel): Metadata => { + const newMap = new Map(metadata) + newMap.delete(label) + return newMap as Metadata +} + +/** + * Get the size (number of entries) of the metadata. + * + * @since 2.0.0 + * @category utilities + */ +export const size = (metadata: Metadata): number => metadata.size + +/** + * Get all labels in the metadata. + * + * @since 2.0.0 + * @category utilities + */ +export const labels = (metadata: Metadata): Array => Array.from(metadata.keys()) + +/** + * Get all metadata values in the metadata. + * + * @since 2.0.0 + * @category utilities + */ +export const values = (metadata: Metadata): Array => + Array.from(metadata.values()) + +/** + * Get all entries in the metadata. + * + * @since 2.0.0 + * @category utilities + */ +export const entries = (metadata: Metadata): Array<[MetadataLabel, TransactionMetadatum.TransactionMetadatum]> => + Array.from(metadata.entries()) + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse Metadata from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new MetadataError({ + message: "Failed to decode Metadata from CBOR bytes", + cause + }) + ) + ) + + /** + * Parse Metadata from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new MetadataError({ + message: "Failed to decode Metadata from CBOR hex", + cause + }) + ) + ) + + /** + * Convert Metadata to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = ( + metadata: Metadata, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(metadata).pipe( + Eff.mapError( + (cause) => + new MetadataError({ + message: "Failed to encode Metadata to CBOR bytes", + cause + }) + ) + ) + + /** + * Convert Metadata to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = ( + metadata: Metadata, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORHex(options))(metadata).pipe( + Eff.mapError( + (cause) => + new MetadataError({ + message: "Failed to encode Metadata to CBOR hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/Mint.ts b/packages/evolution/src/Mint.ts index 31665176..00a44c0e 100644 --- a/packages/evolution/src/Mint.ts +++ b/packages/evolution/src/Mint.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Equal, FastCheck, ParseResult, Pretty, Schema } from "effect" +import { Data, Effect as Eff, Equal, FastCheck, ParseResult, Schema } from "effect" import * as AssetName from "./AssetName.js" import * as Bytes from "./Bytes.js" @@ -15,7 +15,7 @@ import * as PolicyId from "./PolicyId.js" */ export class MintError extends Data.TaggedError("MintError")<{ message?: string - reason?: "InvalidStructure" | "EmptyAssetMap" | "ZeroAmount" | "DuplicateAsset" + cause?: unknown }> {} /** @@ -29,13 +29,10 @@ export class MintError extends Data.TaggedError("MintError")<{ */ export const AssetMap = Schema.MapFromSelf({ key: AssetName.AssetName, - value: NonZeroInt64.NonZeroInt64Schema + value: NonZeroInt64.NonZeroInt64 +}).annotations({ + identifier: "AssetMap" }) - .pipe(Schema.filter((map) => map.size > 0)) - .annotations({ - message: () => "Asset map cannot be empty", - identifier: "AssetMap" - }) export type AssetMap = typeof AssetMap.Type @@ -56,10 +53,11 @@ export const Mint = Schema.MapFromSelf({ key: PolicyId.PolicyId, value: AssetMap }) - .pipe(Schema.filter((map) => map.size > 0)) + .pipe(Schema.brand("Mint")) .annotations({ - message: () => "Mint cannot be empty", - identifier: "Mint" + identifier: "Mint", + title: "Token Mint Operations", + description: "A collection of token minting/burning operations grouped by policy ID" }) /** @@ -72,8 +70,13 @@ export const Mint = Schema.MapFromSelf({ */ export type Mint = typeof Mint.Type -type PrettyPrint = (self: Mint) => string -export const prettyPrint: PrettyPrint = Pretty.make(Mint) +/** + * Check if a value is a valid Mint. + * + * @since 2.0.0 + * @category predicates + */ +export const is = Schema.is(Mint) /** * Create empty Mint. @@ -81,7 +84,7 @@ export const prettyPrint: PrettyPrint = Pretty.make(Mint) * @since 2.0.0 * @category constructors */ -export const empty = (): Mint => new Map() +export const empty = (): Mint => new Map() as Mint /** * Create Mint from a single policy and asset entry. @@ -95,24 +98,27 @@ export const singleton = ( amount: NonZeroInt64.NonZeroInt64 ): Mint => { const assetMap = new Map([[assetName, amount]]) - return new Map([[policyId, assetMap]]) + return new Map([[policyId, assetMap]]) as Mint } /** - * Add or update an asset in the Mint. + * Create Mint from entries array. * * @since 2.0.0 - * @category transformation - */ -/** - * Helper function to create Mint from entries array + * @category constructors */ export const fromEntries = ( entries: Array<[PolicyId.PolicyId, Array<[AssetName.AssetName, NonZeroInt64.NonZeroInt64]>]> ): Mint => { - return new Map(entries.map(([policyId, assetEntries]) => [policyId, new Map(assetEntries)])) + return new Map(entries.map(([policyId, assetEntries]) => [policyId, new Map(assetEntries)])) as Mint } +/** + * Add or update an asset in the Mint. + * + * @since 2.0.0 + * @category transformation + */ export const insert = ( mint: Mint, policyId: PolicyId.PolicyId, @@ -126,7 +132,7 @@ export const insert = ( const result = new Map(mint) result.set(policyId, assetMap) - return result + return result as Mint } /** @@ -138,7 +144,7 @@ export const insert = ( export const removePolicy = (mint: Mint, policyId: PolicyId.PolicyId): Mint => { const result = new Map(mint) result.delete(policyId) - return result + return result as Mint } export const removeAsset = (mint: Mint, policyId: PolicyId.PolicyId, assetName: AssetName.AssetName): Mint => { @@ -153,12 +159,12 @@ export const removeAsset = (mint: Mint, policyId: PolicyId.PolicyId, assetName: // If no assets left, remove the policyId entry const result = new Map(mint) result.delete(policyId) - return result + return result as Mint } const result = new Map(mint) result.set(policyId, updatedAssets) - return result + return result as Mint } /** @@ -212,19 +218,13 @@ export const policyCount = (mint: Mint): number => mint.size */ export const equals = (self: Mint, that: Mint): boolean => Equal.equals(self, that) -/** - * FastCheck generator for Mint instances. - * - * @since 2.0.0 - * @category generators - */ -export const generator = FastCheck.array( - FastCheck.tuple( - PolicyId.generator, - FastCheck.array(FastCheck.tuple(AssetName.generator, NonZeroInt64.generator), { minLength: 1, maxLength: 5 }) - ), - { minLength: 0, maxLength: 5 } -).map((entries) => fromEntries(entries)) +export const CDDLSchema = Schema.MapFromSelf({ + key: CBOR.ByteArray, // Policy ID as 28-byte Uint8Array + value: Schema.MapFromSelf({ + key: CBOR.ByteArray, // Asset name as Uint8Array (variable length) + value: CBOR.Integer // Amount as nonZeroInt64 + }) +}) /** * CDDL schema for Mint as map structure. @@ -240,91 +240,217 @@ export const generator = FastCheck.array( * @since 2.0.0 * @category schemas */ -export const MintCDDLSchema = Schema.transformOrFail( - Schema.MapFromSelf({ - key: CBOR.ByteArray, // Policy ID as Uint8Array (28 bytes) - value: Schema.MapFromSelf({ - key: CBOR.ByteArray, // Asset name as Uint8Array (variable length) - value: CBOR.Integer // Amount as number (will be converted to NonZeroInt64) - }) - }), - Schema.typeSchema(Mint), - { - strict: true, - encode: (toA) => - Effect.gen(function* () { - // Convert Mint to raw Map data for CBOR encoding - const outerMap = new Map>() - - for (const [policyId, assetMap] of toA.entries()) { - const policyIdBytes = yield* ParseResult.encode(PolicyId.FromBytes)(policyId) - const innerMap = new Map() - - for (const [assetName, amount] of assetMap.entries()) { - const assetNameBytes = yield* ParseResult.encode(AssetName.FromBytes)(assetName) - innerMap.set(assetNameBytes, amount) - } - - outerMap.set(policyIdBytes, innerMap) +export const FromCDDL = Schema.transformOrFail(Schema.encodedSchema(CDDLSchema), Schema.typeSchema(Mint), { + strict: true, + encode: (toA) => + Eff.gen(function* () { + // Convert Mint to raw Map data for CBOR encoding + const outerMap = new Map() as Map> + + for (const [policyId, assetMap] of toA.entries()) { + const policyIdBytes = yield* ParseResult.encode(PolicyId.FromBytes)(policyId) + const innerMap = new Map() as Map + + for (const [assetName, amount] of assetMap.entries()) { + const assetNameBytes = yield* ParseResult.encode(AssetName.FromBytes)(assetName) + innerMap.set(assetNameBytes, amount) } - return outerMap - }), + outerMap.set(policyIdBytes, innerMap) + } - decode: (fromA) => - Effect.gen(function* () { - const mint = new Map() + return outerMap + }), - for (const [policyIdBytes, assetMapCddl] of fromA.entries()) { - const policyId = yield* ParseResult.decode(PolicyId.FromBytes)(policyIdBytes) + decode: (fromA) => + Eff.gen(function* () { + const mint = empty() - const assetMap = new Map() - for (const [assetNameBytes, amount] of assetMapCddl.entries()) { - const assetName = yield* ParseResult.decode(AssetName.FromBytes)(assetNameBytes) - const nonZeroAmount = yield* ParseResult.decode(NonZeroInt64.NonZeroInt64Schema)(amount) + for (const [policyIdBytes, assetMapCddl] of fromA.entries()) { + const policyId = yield* ParseResult.decode(PolicyId.FromBytes)(policyIdBytes) - assetMap.set(assetName, nonZeroAmount) - } + const assetMap = new Map() + for (const [assetNameBytes, amount] of assetMapCddl.entries()) { + const assetName = yield* ParseResult.decode(AssetName.FromBytes)(assetNameBytes) + const nonZeroAmount = yield* ParseResult.decode(NonZeroInt64.NonZeroInt64)(amount) - mint.set(policyId, assetMap) + assetMap.set(assetName, nonZeroAmount) } - return mint - }) - } -) + mint.set(policyId, assetMap) + } + + return mint + }) +}) /** * CBOR bytes transformation schema for Mint. - * Transforms between Uint8Array and Mint using CBOR encoding. + * Transforms between CBOR bytes and Mint using CBOR encoding. * * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR - MintCDDLSchema // CBOR → Mint - ) + FromCDDL // CBOR → Mint + ).annotations({ + identifier: "Mint.FromCBORBytes", + title: "Mint from CBOR Bytes", + description: "Transforms CBOR bytes to Mint" + }) /** * CBOR hex transformation schema for Mint. - * Transforms between hex string and Mint using CBOR encoding. + * Transforms between CBOR hex string and Mint using CBOR encoding. * * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → Mint - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - MintError - ) + FromCBORBytes(options) // Uint8Array → Mint + ).annotations({ + identifier: "Mint.FromCBORHex", + title: "Mint from CBOR Hex", + description: "Transforms CBOR hex string to Mint" + }) + +/** + * FastCheck arbitrary for generating random Mint instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.array( + FastCheck.tuple( + PolicyId.arbitrary, + FastCheck.array(FastCheck.tuple(AssetName.arbitrary, NonZeroInt64.arbitrary.map(NonZeroInt64.make)), { + minLength: 1, + maxLength: 5 + }) + ), + { minLength: 0, maxLength: 5 } +).map((entries) => fromEntries(entries)) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse Mint from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Mint => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse Mint from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Mint => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Encode Mint to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (mint: Mint, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(mint, options)) + +/** + * Encode Mint to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (mint: Mint, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(mint, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse Mint from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new MintError({ + message: "Failed to parse Mint from CBOR bytes", + cause + }) + ) + ) + + /** + * Parse Mint from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new MintError({ + message: "Failed to parse Mint from CBOR hex", + cause + }) + ) + ) + + /** + * Encode Mint to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = (mint: Mint, options?: CBOR.CodecOptions): Eff.Effect => + Schema.encode(FromCBORBytes(options))(mint).pipe( + Eff.mapError( + (cause) => + new MintError({ + message: "Failed to encode Mint to CBOR bytes", + cause + }) + ) + ) + + /** + * Encode Mint to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = (mint: Mint, options?: CBOR.CodecOptions): Eff.Effect => + Schema.encode(FromCBORHex(options))(mint).pipe( + Eff.mapError( + (cause) => + new MintError({ + message: "Failed to encode Mint to CBOR hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/MultiAsset.ts b/packages/evolution/src/MultiAsset.ts index cb747315..1c57d0fe 100644 --- a/packages/evolution/src/MultiAsset.ts +++ b/packages/evolution/src/MultiAsset.ts @@ -1,4 +1,4 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as AssetName from "./AssetName.js" import * as Bytes from "./Bytes.js" @@ -24,7 +24,7 @@ export class MultiAssetError extends Data.TaggedError("MultiAssetError")<{ * @since 2.0.0 * @category schemas */ -export const AssetMapSchema = Schema.Map({ +export const AssetMap = Schema.MapFromSelf({ key: AssetName.AssetName, value: PositiveCoin.PositiveCoinSchema }) @@ -40,7 +40,7 @@ export const AssetMapSchema = Schema.Map({ * @since 2.0.0 * @category model */ -export type AssetMap = typeof AssetMapSchema.Type +export type AssetMap = typeof AssetMap.Type /** * Schema for MultiAsset representing native assets. @@ -53,14 +53,17 @@ export type AssetMap = typeof AssetMapSchema.Type * @since 2.0.0 * @category schemas */ -export const MultiAssetSchema = Schema.MapFromSelf({ +export const MultiAsset = Schema.MapFromSelf({ key: PolicyId.PolicyId, - value: AssetMapSchema + value: AssetMap }) .pipe(Schema.filter((map) => map.size > 0)) + .pipe(Schema.brand("MultiAsset")) .annotations({ message: () => "MultiAsset cannot be empty", - identifier: "MultiAsset" + identifier: "MultiAsset", + title: "Multi-Asset Collection", + description: "A collection of native assets grouped by policy ID with positive amounts" }) /** @@ -71,11 +74,19 @@ export const MultiAssetSchema = Schema.MapFromSelf({ * @since 2.0.0 * @category model */ -export type MultiAsset = typeof MultiAssetSchema.Type +export interface MultiAsset extends Schema.Schema.Type {} /** - * Create an empty MultiAsset (note: this will fail validation as MultiAsset cannot be empty). - * Use this only as a starting point for building a MultiAsset. + * Smart constructor for MultiAsset that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Schema.decodeSync(MultiAsset) + +/** + * Create an empty Map for building MultiAssets (note: empty maps will fail validation). + * Use this only as a starting point for building a MultiAsset with add operations. * * @since 2.0.0 * @category constructors @@ -94,7 +105,7 @@ export const singleton = ( amount: PositiveCoin.PositiveCoin ): MultiAsset => { const assetMap = new Map([[assetName, amount]]) - return new Map([[policyId, assetMap]]) + return make(new Map([[policyId, assetMap]])) } /** @@ -120,12 +131,12 @@ export const addAsset = ( const result = new Map(multiAsset) result.set(policyId, updatedAssetMap) - return result + return make(result) } else { const newAssetMap = new Map([[assetName, amount]]) const result = new Map(multiAsset) result.set(policyId, newAssetMap) - return result + return make(result) } } @@ -139,9 +150,9 @@ export const getAsset = (multiAsset: MultiAsset, policyId: PolicyId.PolicyId, as const assetMap = multiAsset.get(policyId) if (assetMap !== undefined) { const amount = assetMap.get(assetName) - return amount !== undefined ? { _tag: "Some" as const, value: amount } : { _tag: "None" as const } + return amount !== undefined ? amount : undefined } - return { _tag: "None" as const } + return undefined } /** @@ -156,7 +167,7 @@ export const hasAsset = ( assetName: AssetName.AssetName ): boolean => { const result = getAsset(multiAsset, policyId, assetName) - return result._tag === "Some" + return result !== undefined } /** @@ -165,7 +176,7 @@ export const hasAsset = ( * @since 2.0.0 * @category transformation */ -export const getPolicyIds = (multiAsset: MultiAsset) => multiAsset.keys() +export const getPolicyIds = (multiAsset: MultiAsset): Array => Array.from(multiAsset.keys()) /** * Get all assets for a specific policy ID. @@ -175,7 +186,7 @@ export const getPolicyIds = (multiAsset: MultiAsset) => multiAsset.keys() */ export const getAssetsByPolicy = (multiAsset: MultiAsset, policyId: PolicyId.PolicyId) => { const assetMap = multiAsset.get(policyId) - return assetMap !== undefined ? { _tag: "Some" as const, value: assetMap } : { _tag: "None" as const } + return assetMap !== undefined ? Array.from(assetMap.entries()) : [] } /** @@ -210,23 +221,23 @@ export const equals = (a: MultiAsset, b: MultiAsset): boolean => * @since 2.0.0 * @category predicates */ -export const is = (value: unknown): value is MultiAsset => Schema.is(MultiAssetSchema)(value) +export const is = (value: unknown): value is MultiAsset => Schema.is(MultiAsset)(value) /** - * Generate a random MultiAsset. + * Change generator to arbitrary and rename CBOR schemas. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.array( +export const arbitrary = FastCheck.array( FastCheck.tuple( - PolicyId.generator, - FastCheck.array(FastCheck.tuple(AssetName.generator, PositiveCoin.generator), { minLength: 1, maxLength: 5 }).map( - (assets) => new Map(assets) + PolicyId.arbitrary, + FastCheck.array(FastCheck.tuple(AssetName.arbitrary, PositiveCoin.arbitrary), { minLength: 1, maxLength: 5 }).map( + (tokens) => new Map(tokens) ) ), - { minLength: 1, maxLength: 3 } -).map((policies) => new Map(policies)) + { minLength: 1, maxLength: 5 } +).map((entries) => make(new Map(entries))) /** * CDDL schema for MultiAsset. @@ -246,11 +257,11 @@ export const MultiAssetCDDLSchema = Schema.transformOrFail( value: CBOR.Integer }) }), - Schema.typeSchema(MultiAssetSchema), + Schema.typeSchema(MultiAsset), { strict: true, encode: (toI, _, __, toA) => - Effect.gen(function* () { + Eff.gen(function* () { // Convert MultiAsset to raw Map data for CBOR encoding const outerMap = new Map>() @@ -270,7 +281,7 @@ export const MultiAssetCDDLSchema = Schema.transformOrFail( }), decode: (fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const result = new Map() for (const [policyIdBytes, assetMapCddl] of fromA.entries()) { @@ -286,43 +297,85 @@ export const MultiAssetCDDLSchema = Schema.transformOrFail( result.set(policyId, assetMap) } - return result + return yield* ParseResult.decode(MultiAsset)(result) }) } ) /** * CBOR bytes transformation schema for MultiAsset. + * Transforms between CBOR bytes and MultiAsset using CBOR encoding. * * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR MultiAssetCDDLSchema // CBOR → MultiAsset - ) + ).annotations({ + identifier: "MultiAsset.FromCBORBytes", + title: "MultiAsset from CBOR Bytes", + description: "Transforms CBOR bytes to MultiAsset" + }) /** * CBOR hex transformation schema for MultiAsset. + * Transforms between CBOR hex string and MultiAsset using CBOR encoding. * * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → MultiAsset - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - MultiAssetError - ) + FromCBORBytes(options) // Uint8Array → MultiAsset + ).annotations({ + identifier: "MultiAsset.FromCBORHex", + title: "MultiAsset from CBOR Hex", + description: "Transforms CBOR hex string to MultiAsset" + }) + +/** + * Root Functions + * ============================================================================ + */ + +/** + * Parse MultiAsset from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): MultiAsset => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse MultiAsset from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): MultiAsset => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Encode MultiAsset to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (multiAsset: MultiAsset, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(multiAsset, options)) + +/** + * Encode MultiAsset to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (multiAsset: MultiAsset, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(multiAsset, options)) /** * Merge two MultiAsset instances, combining amounts for assets that exist in both. @@ -396,5 +449,93 @@ export const subtract = (a: MultiAsset, b: MultiAsset): MultiAsset => { }) } - return result + return make(result) } + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse MultiAsset from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options?: CBOR.CodecOptions + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new MultiAssetError({ + message: "Failed to parse MultiAsset from CBOR bytes", + cause + }) + ) + ) + + /** + * Parse MultiAsset from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new MultiAssetError({ + message: "Failed to parse MultiAsset from CBOR hex", + cause + }) + ) + ) + + /** + * Encode MultiAsset to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = ( + multiAsset: MultiAsset, + options?: CBOR.CodecOptions + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(multiAsset).pipe( + Eff.mapError( + (cause) => + new MultiAssetError({ + message: "Failed to encode MultiAsset to CBOR bytes", + cause + }) + ) + ) + + /** + * Encode MultiAsset to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = (multiAsset: MultiAsset, options?: CBOR.CodecOptions): Eff.Effect => + Schema.encode(FromCBORHex(options))(multiAsset).pipe( + Eff.mapError( + (cause) => + new MultiAssetError({ + message: "Failed to encode MultiAsset to CBOR hex", + cause + }) + ) + ) +} + +// ============================================================================ diff --git a/packages/evolution/src/MultiHostName.ts b/packages/evolution/src/MultiHostName.ts index 0318a177..684c5e19 100644 --- a/packages/evolution/src/MultiHostName.ts +++ b/packages/evolution/src/MultiHostName.ts @@ -1,8 +1,7 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as DnsName from "./DnsName.js" /** @@ -43,17 +42,20 @@ export const FromCDDL = Schema.transformOrFail( { strict: true, encode: (toA) => - Effect.gen(function* () { + Eff.gen(function* () { const dnsName = yield* ParseResult.encode(DnsName.DnsName)(toA.dnsName) - return yield* Effect.succeed([2n, dnsName] as const) + return yield* Eff.succeed([2n, dnsName] as const) }), decode: ([, dnsNameValue]) => - Effect.gen(function* () { + Eff.gen(function* () { const dnsName = yield* ParseResult.decode(DnsName.DnsName)(dnsNameValue) - return yield* Effect.succeed(new MultiHostName({ dnsName })) + return yield* Eff.succeed(new MultiHostName({ dnsName })) }) } -) +).annotations({ + identifier: "MultiHostName.FromCDDL", + description: "Transforms CBOR structure to MultiHostName" +}) /** * CBOR bytes transformation schema for MultiHostName. @@ -61,11 +63,14 @@ export const FromCDDL = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → MultiHostName - ) + ).annotations({ + identifier: "MultiHostName.FromCBORBytes", + description: "Transforms CBOR bytes to MultiHostName" + }) /** * CBOR hex transformation schema for MultiHostName. @@ -73,11 +78,14 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → MultiHostName - ) + FromCBORBytes(options) // Uint8Array → MultiHostName + ).annotations({ + identifier: "MultiHostName.FromCBORHex", + description: "Transforms CBOR hex string to MultiHostName" + }) /** * Create a MultiHostName instance. @@ -96,20 +104,103 @@ export const make = (dnsName: DnsName.DnsName): MultiHostName => new MultiHostNa export const equals = (self: MultiHostName, that: MultiHostName): boolean => DnsName.equals(self.dnsName, that.dnsName) /** - * FastCheck generator for MultiHostName instances. + * FastCheck arbitrary for MultiHostName instances. * * @since 2.0.0 - * @category generators + * @category testing */ -export const generator = FastCheck.record({ - dnsName: DnsName.generator +export const arbitrary = FastCheck.record({ + dnsName: DnsName.arbitrary }).map((props) => new MultiHostName(props)) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - MultiHostNameError - ) +/** + * Effect namespace for MultiHostName operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert CBOR bytes to MultiHostName using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (cause) => new MultiHostNameError({ message: "Failed to decode from CBOR bytes", cause }) + ) + + /** + * Convert CBOR hex string to MultiHostName using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (cause) => new MultiHostNameError({ message: "Failed to decode from CBOR hex", cause }) + ) + + /** + * Convert MultiHostName to CBOR bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORBytes = (hostName: MultiHostName, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(hostName), + (cause) => new MultiHostNameError({ message: "Failed to encode to CBOR bytes", cause }) + ) + + /** + * Convert MultiHostName to CBOR hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORHex = (hostName: MultiHostName, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(hostName), + (cause) => new MultiHostNameError({ message: "Failed to encode to CBOR hex", cause }) + ) +} + +/** + * Convert CBOR bytes to MultiHostName (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): MultiHostName => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Convert CBOR hex string to MultiHostName (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): MultiHostName => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Convert MultiHostName to CBOR bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORBytes = (hostName: MultiHostName, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(hostName, options)) + +/** + * Convert MultiHostName to CBOR hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORHex = (hostName: MultiHostName, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(hostName, options)) diff --git a/packages/evolution/src/NativeScripts.ts b/packages/evolution/src/NativeScripts.ts index 38b63dca..1dce2298 100644 --- a/packages/evolution/src/NativeScripts.ts +++ b/packages/evolution/src/NativeScripts.ts @@ -1,165 +1,103 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, ParseResult, Schema } from "effect" import type { ParseIssue } from "effect/ParseResult" +import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" -import { Bytes } from "./index.js" -import * as KeyHash from "./KeyHash.js" -import * as NativeScriptJSON from "./NativeScriptJSON.js" -import * as Numeric from "./Numeric.js" -export class NativeScriptError extends Data.TaggedError("NativeScriptError")<{ +/** + * Error class for Native script related operations. + * + * @since 2.0.0 + * @category errors + */ +export class NativeError extends Data.TaggedError("NativeError")<{ message?: string cause?: unknown }> {} -// CDDL specs for native scripts -// native_script = -// [ script_pubkey -// // script_all -// // script_any -// // script_n_of_k -// // invalid_before -// // invalid_hereafter -// ] - -// script_pubkey = (0, addr_keyhash) -// addr_keyhash = hash28 -// script_all = (1, [* native_script]) -// script_any = (2, [* native_script]) -// script_n_of_k = (3, n : int64, [* native_script]) -// invalid_before = (4, slot_no) -// invalid_hereafter = (5, slot_no) -// slot_no = uint .size 8 - /** - * Schema for slot numbers used in time-based native script constraints. + * Type representing a native script following cardano-cli JSON syntax. * * @since 2.0.0 - * @category schemas + * @category model */ -export const SlotNumber = Numeric.Uint8Schema - -export type SlotNumber = typeof SlotNumber.Type - -// /** -// * @since 2.0.0 -// * @category model -// */ -export type NativeScript = ScriptPubKey | ScriptAll | ScriptAny | ScriptNOfK | InvalidBefore | InvalidHereafter - -export type NativeScriptEncoded = - | ScriptPubKeyEncoded - | ScriptAllEncoded - | ScriptAnyEncoded - | ScriptNOfKEncoded - | InvalidBeforeEncoded - | InvalidHereafterEncoded - -export interface ScriptPubKeyEncoded { - readonly _tag: "ScriptPubKey" - readonly keyHash: typeof KeyHash.KeyHash.Encoded -} -export interface ScriptAllEncoded { - readonly _tag: "ScriptAll" - readonly scripts: ReadonlyArray -} -export interface ScriptAnyEncoded { - readonly _tag: "ScriptAny" - readonly scripts: ReadonlyArray -} -export interface ScriptNOfKEncoded { - readonly _tag: "ScriptNOfK" - readonly n: bigint - readonly scripts: ReadonlyArray -} - -export interface InvalidBeforeEncoded { - readonly _tag: "InvalidBefore" - readonly slot: number -} - -export interface InvalidHereafterEncoded { - readonly _tag: "InvalidHereafter" - readonly slot: number -} - -export class ScriptPubKey extends Schema.TaggedClass("ScriptPubKey")("ScriptPubKey", { - keyHash: KeyHash.KeyHash -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: this._tag, - keyHash: this.keyHash +export type Native = + | { + type: "sig" + keyHash: string } - } -} - -export class ScriptAll extends Schema.TaggedClass("ScriptAll")("ScriptAll", { - scripts: Schema.Array(Schema.suspend((): Schema.Schema => NativeScript)) -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: this._tag, - scripts: this.scripts.map((script) => script) + | { + type: "before" + slot: number } - } -} - -export class ScriptAny extends Schema.TaggedClass("ScriptAny")("ScriptAny", { - scripts: Schema.Array(Schema.suspend((): Schema.Schema => NativeScript)) -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: this._tag, - scripts: this.scripts.map((script) => script) + | { + type: "after" + slot: number } - } -} - -export class ScriptNOfK extends Schema.TaggedClass("ScriptNOfK")("ScriptNOfK", { - n: Numeric.Int64, - scripts: Schema.Array(Schema.suspend((): Schema.Schema => NativeScript)) -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: this._tag, - n: this.n, - scripts: this.scripts.map((script) => script) + | { + type: "all" + scripts: ReadonlyArray } - } -} - -export class InvalidBefore extends Schema.TaggedClass("InvalidBefore")("InvalidBefore", { - slot: SlotNumber -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: this._tag, - slot: this.slot + | { + type: "any" + scripts: ReadonlyArray } - } -} - -export class InvalidHereafter extends Schema.TaggedClass("InvalidHereafter")("InvalidHereafter", { - slot: SlotNumber -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: this._tag, - slot: this.slot + | { + type: "atLeast" + required: number + scripts: ReadonlyArray } - } -} -export const NativeScript = Schema.Union( - ScriptPubKey, - ScriptAll, - ScriptAny, - ScriptNOfK, - InvalidBefore, - InvalidHereafter -) +/** + * Represents a cardano-cli JSON script syntax + * + * Native type follows the standard described in the + * {@link https://github.com/IntersectMBO/cardano-node/blob/1.26.1-with-cardano-cli/doc/reference/simple-scripts.md#json-script-syntax JSON script syntax documentation}. + * + * @since 2.0.0 + * @category schemas + */ +export const NativeSchema: Schema.Schema = Schema.Union( + Schema.Struct({ + type: Schema.Literal("sig"), + keyHash: Schema.String + }), + Schema.Struct({ + type: Schema.Literal("before"), + slot: Schema.Number + }), + Schema.Struct({ + type: Schema.Literal("after"), + slot: Schema.Number + }), + Schema.Struct({ + type: Schema.Literal("all"), + scripts: Schema.Array(Schema.suspend((): Schema.Schema => NativeSchema)) + }), + Schema.Struct({ + type: Schema.Literal("any"), + scripts: Schema.Array(Schema.suspend((): Schema.Schema => NativeSchema)) + }), + Schema.Struct({ + type: Schema.Literal("atLeast"), + required: Schema.Number, + scripts: Schema.Array(Schema.suspend((): Schema.Schema => NativeSchema)) + }) +).annotations({ + identifier: "Native", + title: "Native Script", + description: "A native script following cardano-cli JSON syntax" +}) + +export const Native = NativeSchema + +/** + * Smart constructor for Native that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = (native: Native): Native => native /** * CDDL schemas for native scripts. @@ -178,27 +116,27 @@ export const NativeScript = Schema.Union( * @category schemas */ -const ScriptPubKeyCDDL = Schema.Tuple(Schema.Literal(0), Schema.Uint8ArrayFromSelf) +const ScriptPubKeyCDDL = Schema.Tuple(Schema.Literal(0n), Schema.Uint8ArrayFromSelf) const ScriptAllCDDL = Schema.Tuple( - Schema.Literal(1), - Schema.Array(Schema.suspend((): Schema.Schema => Schema.encodedSchema(NativeScriptCDDL))) + Schema.Literal(1n), + Schema.Array(Schema.suspend((): Schema.Schema => Schema.encodedSchema(FromCDDL))) ) const ScriptAnyCDDL = Schema.Tuple( - Schema.Literal(2), - Schema.Array(Schema.suspend((): Schema.Schema => Schema.encodedSchema(NativeScriptCDDL))) + Schema.Literal(2n), + Schema.Array(Schema.suspend((): Schema.Schema => Schema.encodedSchema(FromCDDL))) ) const ScriptNOfKCDDL = Schema.Tuple( - Schema.Literal(3), - Schema.BigIntFromSelf, - Schema.Array(Schema.suspend((): Schema.Schema => Schema.encodedSchema(NativeScriptCDDL))) + Schema.Literal(3n), + CBOR.Integer, + Schema.Array(Schema.suspend((): Schema.Schema => Schema.encodedSchema(FromCDDL))) ) -const InvalidBeforeCDDL = Schema.Tuple(Schema.Literal(4), Schema.BigIntFromSelf) +const InvalidBeforeCDDL = Schema.Tuple(Schema.Literal(4n), CBOR.Integer) -const InvalidHereafterCDDL = Schema.Tuple(Schema.Literal(5), Schema.BigIntFromSelf) +const InvalidHereafterCDDL = Schema.Tuple(Schema.Literal(5n), CBOR.Integer) /** * CDDL representation of a native script as a union of tuple types. @@ -209,261 +147,304 @@ const InvalidHereafterCDDL = Schema.Tuple(Schema.Literal(5), Schema.BigIntFromSe * @since 2.0.0 * @category model */ -export type NativeScriptCDDL = - | readonly [0, Uint8Array] - | readonly [1, ReadonlyArray] - | readonly [2, ReadonlyArray] - | readonly [3, bigint, ReadonlyArray] - | readonly [4, bigint] - | readonly [5, bigint] +export type NativeCDDL = + | readonly [0n, Uint8Array] + | readonly [1n, ReadonlyArray] + | readonly [2n, ReadonlyArray] + | readonly [3n, bigint, ReadonlyArray] + | readonly [4n, bigint] + | readonly [5n, bigint] + +export const CDDLSchema = Schema.Union( + ScriptPubKeyCDDL, + ScriptAllCDDL, + ScriptAnyCDDL, + ScriptNOfKCDDL, + InvalidBeforeCDDL, + InvalidHereafterCDDL +) /** - * Schema for NativeScriptCDDL union type. + * Schema for NativeCDDL union type. * * @since 2.0.0 * @category schemas */ -export const NativeScriptCDDL = Schema.transformOrFail( - Schema.Union(ScriptPubKeyCDDL, ScriptAllCDDL, ScriptAnyCDDL, ScriptNOfKCDDL, InvalidBeforeCDDL, InvalidHereafterCDDL), - Schema.typeSchema(NativeScript), - { - strict: true, - encode: (nativeScript) => internalEncodeCDDL(nativeScript), - decode: (cborTuple) => internalDecodeCDDL(cborTuple) - } -) +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Native), { + strict: true, + encode: (native) => internalEncodeCDDL(native), + decode: (cborTuple) => internalDecodeCDDL(cborTuple) +}) /** - * Convert a NativeScript to its CDDL representation. + * Convert a Native to its CDDL representation. * * @since 2.0.0 * @category encoding */ -export const internalEncodeCDDL = (nativeScript: NativeScript): Effect.Effect => - Effect.gen(function* () { - switch (nativeScript._tag) { - case "ScriptPubKey": { - return [0, yield* ParseResult.encode(KeyHash.FromBytes)(nativeScript.keyHash)] +export const internalEncodeCDDL = (native: Native): Eff.Effect => + Eff.gen(function* () { + switch (native.type) { + case "sig": { + // Convert hex string keyHash to bytes for CBOR encoding + const keyHashBytes = yield* ParseResult.decode(Bytes.FromHex)(native.keyHash) + return [0n, keyHashBytes] as const } - case "ScriptAll": { - const scripts = yield* Effect.forEach(nativeScript.scripts, internalEncodeCDDL) - return [1, scripts] as const + case "all": { + const scripts = yield* Eff.forEach(native.scripts, internalEncodeCDDL) + return [1n, scripts] as const } - case "ScriptAny": { - const scripts = yield* Effect.forEach(nativeScript.scripts, internalEncodeCDDL) - return [2, scripts] as const + case "any": { + const scripts = yield* Eff.forEach(native.scripts, internalEncodeCDDL) + return [2n, scripts] as const } - case "ScriptNOfK": { - const scripts = yield* Effect.forEach(nativeScript.scripts, internalEncodeCDDL) - return [3, nativeScript.n, scripts] as const + case "atLeast": { + const scripts = yield* Eff.forEach(native.scripts, internalEncodeCDDL) + return [3n, BigInt(native.required), scripts] as const } - case "InvalidBefore": { - return [4, BigInt(nativeScript.slot)] as const + case "before": { + return [4n, BigInt(native.slot)] as const } - case "InvalidHereafter": { - return [5, BigInt(nativeScript.slot)] as const + case "after": { + return [5n, BigInt(native.slot)] as const } } }) /** - * Convert a CDDL representation back to a NativeScript. + * Convert a CDDL representation back to a Native. * * This function recursively decodes nested CBOR scripts and constructs - * the appropriate NativeScript instances. + * the appropriate Native instances. * * @since 2.0.0 * @category decoding */ -export const internalDecodeCDDL = (cborTuple: NativeScriptCDDL): Effect.Effect => - Effect.gen(function* () { +export const internalDecodeCDDL = (cborTuple: NativeCDDL): Eff.Effect => + Eff.gen(function* () { switch (cborTuple[0]) { - case 0: { - // ScriptPubKey: [0, keyHash_bytes] + case 0n: { + // sig: [0, keyHash_bytes] - convert bytes back to hex string const [, keyHashBytes] = cborTuple - const keyHash = yield* ParseResult.decode(KeyHash.FromBytes)(keyHashBytes) - return new ScriptPubKey({ keyHash }) + const keyHash = yield* ParseResult.encode(Bytes.FromHex)(keyHashBytes) + return { + type: "sig" as const, + keyHash + } } - case 1: { - // ScriptAll: [1, [native_script, ...]] + case 1n: { + // all: [1, [native_script, ...]] const [, scriptCBORs] = cborTuple - const scripts: Array = [] + const scripts: Array = [] for (const scriptCBOR of scriptCBORs) { const script = yield* internalDecodeCDDL(scriptCBOR) scripts.push(script) } - return new ScriptAll({ scripts }) + return { + type: "all" as const, + scripts + } } - case 2: { - // ScriptAny: [2, [native_script, ...]] + case 2n: { + // any: [2, [native_script, ...]] const [, scriptCBORs] = cborTuple - const scripts: Array = [] + const scripts: Array = [] for (const scriptCBOR of scriptCBORs) { const script = yield* internalDecodeCDDL(scriptCBOR) scripts.push(script) } - return new ScriptAny({ scripts }) + return { + type: "any" as const, + scripts + } } - case 3: { - // ScriptNOfK: [3, n, [native_script, ...]] - const [, n, scriptCBORs] = cborTuple - const scripts: Array = [] + case 3n: { + // atLeast: [3, required, [native_script, ...]] + const [, required, scriptCBORs] = cborTuple + const scripts: Array = [] for (const scriptCBOR of scriptCBORs) { const script = yield* internalDecodeCDDL(scriptCBOR) scripts.push(script) } - return new ScriptNOfK({ - n: Numeric.Int64.make(n), + return { + type: "atLeast" as const, + required: Number(required), scripts - }) + } } - case 4: { - // InvalidBefore: [4, slot] + case 4n: { + // before: [4, slot] const [, slot] = cborTuple - return new InvalidBefore({ + return { + type: "before" as const, slot: Number(slot) - }) + } } - case 5: { - // InvalidHereafter: [5, slot] + case 5n: { + // after: [5, slot] const [, slot] = cborTuple - return new InvalidHereafter({ + return { + type: "after" as const, slot: Number(slot) - }) + } } default: // This should never happen with proper CBOR validation - throw new Error(`Invalid native script tag: ${cborTuple[0]}`) + return yield* Eff.fail(new ParseResult.Type(Schema.Literal(0, 1, 2, 3, 4, 5).ast, cborTuple[0])) } }) -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +/** + * CBOR bytes transformation schema for Native. + * Transforms between CBOR bytes and Native using CBOR encoding. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR - NativeScriptCDDL // CBOR → NativeScript - ) + FromCDDL // CBOR → Native + ).annotations({ + identifier: "Native.FromCBORBytes", + title: "Native from CBOR Bytes", + description: "Transforms CBOR bytes to Native" + }) -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +/** + * CBOR hex transformation schema for Native. + * Transforms between CBOR hex string and Native using CBOR encoding. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → NativeScript - ) + FromCBORBytes(options) // Uint8Array → Native + ).annotations({ + identifier: "Native.FromCBORHex", + title: "Native from CBOR Hex", + description: "Transforms CBOR hex string to Native" + }) + +/** + * Root Functions + * ============================================================================ + */ /** - * Schema transformer for converting between NativeJSON and NativeScript. + * Parse Native from CBOR bytes. * * @since 2.0.0 - * @category schemas + * @category parsing */ -export const NativeJSON = Schema.transformOrFail(NativeScriptJSON.NativeJSONSchema, NativeScript, { - strict: true, - encode: (_, __, ___, toI) => internalNativeToJson(toI), - decode: (fromA) => internalJSONToNative(fromA) -}) +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Native => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) /** - * Convert a NativeJSON to a NativeScript. + * Parse Native from CBOR hex string. * * @since 2.0.0 - * @category conversion + * @category parsing */ -export const internalJSONToNative = ( - nativeJSON: NativeScriptJSON.NativeJSON -): Effect.Effect => - Effect.gen(function* () { - switch (nativeJSON.type) { - case "sig": { - const keyHash = yield* ParseResult.decode(KeyHash.FromHex)(nativeJSON.keyHash) - return new ScriptPubKey({ keyHash }) - } - case "before": { - return new InvalidBefore({ slot: nativeJSON.slot }) - } - case "after": { - return new InvalidHereafter({ slot: nativeJSON.slot }) - } - case "all": { - const scripts = yield* Effect.forEach(nativeJSON.scripts, internalJSONToNative) - return new ScriptAll({ scripts }) - } - case "any": { - const scripts = yield* Effect.forEach(nativeJSON.scripts, internalJSONToNative) - return new ScriptAny({ scripts }) - } - case "atLeast": { - const scripts = yield* Effect.forEach(nativeJSON.scripts, internalJSONToNative) - return new ScriptNOfK({ - n: Numeric.Int64.make(BigInt(nativeJSON.required)), - scripts - }) - } - } - }) +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Native => + Eff.runSync(Effect.fromCBORHex(hex, options)) /** - * Convert a NativeScript to a NativeJSON. + * Encode Native to CBOR bytes. * * @since 2.0.0 - * @category conversion + * @category encoding */ -export const internalNativeToJson = ( - nativeScript: NativeScript -): Effect.Effect => - Effect.gen(function* () { - switch (nativeScript._tag) { - case "ScriptPubKey": { - return { - type: "sig" as const, - keyHash: yield* ParseResult.encode(KeyHash.FromHex)(nativeScript.keyHash) - } - } - case "ScriptAll": { - const scripts = yield* Effect.forEach(nativeScript.scripts, internalNativeToJson) - return { - type: "all" as const, - scripts - } - } - case "ScriptAny": { - const scripts = yield* Effect.forEach(nativeScript.scripts, internalNativeToJson) - return { - type: "any" as const, - scripts - } - } - case "ScriptNOfK": { - const scripts = yield* Effect.forEach(nativeScript.scripts, internalNativeToJson) - return { - type: "atLeast" as const, - required: Number(nativeScript.n), - scripts - } - } - case "InvalidBefore": { - return { - type: "before" as const, - slot: nativeScript.slot - } - } - case "InvalidHereafter": { - return { - type: "after" as const, - slot: nativeScript.slot - } - } - default: - throw new Error( - `Exhaustive check failed: Unhandled case '${(nativeScript as unknown as { _tag: string })._tag}' encountered.` - ) - } - }) +export const toCBORBytes = (native: Native, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(native, options)) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options), - nativeJSON: NativeJSON - }, - NativeScriptError - ) +/** + * Encode Native to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (native: Native, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(native, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse Native from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new NativeError({ + message: "Failed to parse Native from CBOR bytes", + cause + }) + ) + ) + + /** + * Parse Native from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new NativeError({ + message: "Failed to parse Native from CBOR hex", + cause + }) + ) + ) + + /** + * Encode Native to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = (native: Native, options?: CBOR.CodecOptions): Eff.Effect => + Schema.encode(FromCBORBytes(options))(native).pipe( + Eff.mapError( + (cause) => + new NativeError({ + message: "Failed to encode Native to CBOR bytes", + cause + }) + ) + ) + + /** + * Encode Native to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = (native: Native, options?: CBOR.CodecOptions): Eff.Effect => + Schema.encode(FromCBORHex(options))(native).pipe( + Eff.mapError( + (cause) => + new NativeError({ + message: "Failed to encode Native to CBOR hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/Natural.ts b/packages/evolution/src/Natural.ts index a1961110..b581b9bb 100644 --- a/packages/evolution/src/Natural.ts +++ b/packages/evolution/src/Natural.ts @@ -1,23 +1,150 @@ -import { FastCheck, Schema } from "effect" +import { Data, Either as E, FastCheck, Schema } from "effect" /** - * Natural number constructors - * Used for validating non negative integers + * Error class for Natural related operations. * * @since 2.0.0 + * @category errors */ -export const Natural = Schema.Positive.pipe(Schema.brand("Natural")) +export class NaturalError extends Data.TaggedError("NaturalError")<{ + message?: string + cause?: unknown +}> {} +/** + * Natural number schema for positive integers. + * Used for validating non-negative integers greater than 0. + * + * @since 2.0.0 + * @category schemas + */ +export const Natural = Schema.Positive.pipe(Schema.brand("Natural")).annotations({ + identifier: "Natural", + title: "Natural Number", + description: "A positive integer greater than 0" +}) + +/** + * Type alias for Natural representing positive integers. + * + * @since 2.0.0 + * @category model + */ export type Natural = typeof Natural.Type /** - * Check if the given value is a valid PositiveNumber + * Smart constructor for Natural that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Natural.make + +/** + * Check if two Natural instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Natural, b: Natural): boolean => a === b + +/** + * Check if the given value is a valid Natural * * @since 2.0.0 * @category predicates */ +export const isNatural = Schema.is(Natural) -export const generator = FastCheck.integer({ +/** + * FastCheck arbitrary for generating random Natural instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.integer({ min: 1, max: Number.MAX_SAFE_INTEGER -}).map((number) => Natural.make(number)) +}).map((number) => number as Natural) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse Natural from number. + * + * @since 2.0.0 + * @category parsing + */ +export const fromNumber = (num: number): Natural => { + try { + return Schema.decodeSync(Natural)(num) + } catch (cause) { + throw new NaturalError({ + message: "Failed to parse Natural from number", + cause + }) + } +} + +/** + * Encode Natural to number. + * + * @since 2.0.0 + * @category encoding + */ +export const toNumber = (natural: Natural): number => { + try { + return Schema.encodeSync(Natural)(natural) + } catch (cause) { + throw new NaturalError({ + message: "Failed to encode Natural to number", + cause + }) + } +} + +// ============================================================================ +// Either Namespace +// ============================================================================ + +/** + * Either-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category either + */ +export namespace Either { + /** + * Parse Natural from number with Either error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromNumber = (num: number): E.Either => + E.mapLeft( + Schema.decodeEither(Natural)(num), + (cause) => + new NaturalError({ + message: "Failed to parse Natural from number", + cause + }) + ) + + /** + * Encode Natural to number with Either error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toNumber = (natural: Natural): E.Either => + E.mapLeft( + Schema.encodeEither(Natural)(natural), + (cause) => + new NaturalError({ + message: "Failed to encode Natural to number", + cause + }) + ) +} diff --git a/packages/evolution/src/Network.ts b/packages/evolution/src/Network.ts index ab2eb161..54dfc331 100644 --- a/packages/evolution/src/Network.ts +++ b/packages/evolution/src/Network.ts @@ -1,35 +1,179 @@ -import { Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as NetworkId from "./NetworkId.js" -const Network = Schema.Literal("Mainnet", "Preview", "Preprod", "Custom") -type Network = typeof Network.Type +/** + * Error class for Network related operations. + * + * @since 2.0.0 + * @category errors + */ +export class NetworkError extends Data.TaggedError("NetworkError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Schema for Network representing Cardano network types. + * Supports Mainnet, Preview, Preprod, and Custom networks. + * + * @since 2.0.0 + * @category schemas + */ +export const Network = Schema.String.pipe( + Schema.filter( + (str): str is "Mainnet" | "Preview" | "Preprod" | "Custom" => + str === "Mainnet" || str === "Preview" || str === "Preprod" || str === "Custom" + ), + Schema.brand("Network") +).annotations({ + identifier: "Network", + title: "Cardano Network", + description: "A Cardano network type (Mainnet, Preview, Preprod, or Custom)" +}) + +/** + * Type alias for Network representing Cardano network types. + * + * @since 2.0.0 + * @category model + */ +export type Network = typeof Network.Type + +/** + * Smart constructor for Network that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = Schema.decodeSync(Network) + +/** + * Check if two Network instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Network, b: Network): boolean => a === b + +/** + * Check if a value is a valid Network. + * + * @since 2.0.0 + * @category predicates + */ +export const is = (value: unknown): value is Network => Schema.is(Network)(value) /** - * Converts a Network type to Id number + * FastCheck arbitrary for generating random Network instances. * - * @since 1.0.0 + * @since 2.0.0 + * @category arbitrary */ -const _toId = (network: T): NetworkId.NetworkId => { +export const arbitrary = FastCheck.constantFrom("Mainnet", "Preview", "Preprod", "Custom").map((literal) => + make(literal) +) + +/** + * Converts a Network type to NetworkId number. + * + * @since 2.0.0 + * @category conversion + */ +export const toId = (network: T): NetworkId.NetworkId => { switch (network) { case "Preview": case "Preprod": case "Custom": - return NetworkId.NetworkId.make(0) + return NetworkId.make(0) case "Mainnet": - return NetworkId.NetworkId.make(1) + return NetworkId.make(1) default: throw new Error(`Exhaustive check failed: Unhandled case ${network}`) } } -const _fromId = (id: NetworkId.NetworkId): Network => { +/** + * Converts a NetworkId to Network type. + * + * @since 2.0.0 + * @category conversion + */ +export const fromId = (id: NetworkId.NetworkId): Network => { switch (id) { case 0: - return "Preview" + return make("Preview") case 1: - return "Mainnet" + return make("Mainnet") default: throw new Error(`Exhaustive check failed: Unhandled case ${id}`) } } + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse Network from string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromString = (str: string): Network => Eff.runSync(Effect.fromString(str)) + +/** + * Encode Network to string. + * + * @since 2.0.0 + * @category encoding + */ +export const toString = (network: Network): string => Eff.runSync(Effect.toString(network)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse Network from string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromString = (str: string): Eff.Effect => + Schema.decode(Network)(str).pipe( + Eff.mapError( + (cause) => + new NetworkError({ + message: "Failed to parse Network from string", + cause + }) + ) + ) + + /** + * Encode Network to string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toString = (network: Network): Eff.Effect => + Schema.encode(Network)(network).pipe( + Eff.mapError( + (cause) => + new NetworkError({ + message: "Failed to encode Network to string", + cause + }) + ) + ) +} + +// ============================================================================ diff --git a/packages/evolution/src/NetworkId.ts b/packages/evolution/src/NetworkId.ts index fbe92cea..fbf27881 100644 --- a/packages/evolution/src/NetworkId.ts +++ b/packages/evolution/src/NetworkId.ts @@ -1,12 +1,53 @@ -import { FastCheck, Schema } from "effect" +import { Data, FastCheck, Schema } from "effect" -export const NetworkId = Schema.NonNegativeInt.pipe(Schema.brand("NetworkId")) +/** + * Error class for NetworkId related operations. + * + * @since 2.0.0 + * @category errors + */ +export class NetworkIdError extends Data.TaggedError("NetworkIdError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Schema for NetworkId representing a Cardano network identifier. + * 0 = Testnet, 1 = Mainnet + * + * @since 2.0.0 + * @category schemas + */ +export const NetworkId = Schema.NonNegativeInt.pipe(Schema.brand("NetworkId")).annotations({ + identifier: "NetworkId" +}) export type NetworkId = typeof NetworkId.Type +/** + * Smart constructor for NetworkId that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ export const make = NetworkId.make -export const generator = FastCheck.integer({ +/** + * Check if two NetworkId instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: NetworkId, b: NetworkId): boolean => a === b + +/** + * FastCheck generator for creating NetworkId instances. + * Generates values 0 (Testnet) or 1 (Mainnet). + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.integer({ min: 0, max: 2 }).map((number) => make(number)) diff --git a/packages/evolution/src/NonZeroInt64.ts b/packages/evolution/src/NonZeroInt64.ts index 7a0cbc6f..25e4d8de 100644 --- a/packages/evolution/src/NonZeroInt64.ts +++ b/packages/evolution/src/NonZeroInt64.ts @@ -1,4 +1,4 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Either as E, FastCheck, Schema } from "effect" /** * Constants for NonZeroInt64 validation. @@ -31,11 +31,10 @@ export class NonZeroInt64Error extends Data.TaggedError("NonZeroInt64Error")<{ * @since 2.0.0 * @category schemas */ -export const NegInt64Schema = pipe( - Schema.BigIntFromSelf, - Schema.filter((value) => value >= NEG_INT64_MIN && value <= NEG_INT64_MAX) +export const NegInt64Schema = Schema.BigIntFromSelf.pipe( + Schema.filter((value: bigint) => value >= NEG_INT64_MIN && value <= NEG_INT64_MAX) ).annotations({ - message: (issue) => `NegInt64 must be between ${NEG_INT64_MIN} and ${NEG_INT64_MAX}, but got ${issue.actual}`, + message: (issue: any) => `NegInt64 must be between ${NEG_INT64_MIN} and ${NEG_INT64_MAX}, but got ${issue.actual}`, identifier: "NegInt64" }) @@ -45,11 +44,10 @@ export const NegInt64Schema = pipe( * @since 2.0.0 * @category schemas */ -export const PosInt64Schema = pipe( - Schema.BigIntFromSelf, - Schema.filter((value) => value >= POS_INT64_MIN && value <= POS_INT64_MAX) +export const PosInt64Schema = Schema.BigIntFromSelf.pipe( + Schema.filter((value: bigint) => value >= POS_INT64_MIN && value <= POS_INT64_MAX) ).annotations({ - message: (issue) => `PosInt64 must be between ${POS_INT64_MIN} and ${POS_INT64_MAX}, but got ${issue.actual}`, + message: (issue: any) => `PosInt64 must be between ${POS_INT64_MIN} and ${POS_INT64_MAX}, but got ${issue.actual}`, identifier: "PosInt64" }) @@ -60,9 +58,13 @@ export const PosInt64Schema = pipe( * @since 2.0.0 * @category schemas */ -export const NonZeroInt64Schema = Schema.Union(NegInt64Schema, PosInt64Schema).annotations({ - identifier: "NonZeroInt64" -}) +export const NonZeroInt64 = Schema.Union(NegInt64Schema, PosInt64Schema) + .pipe(Schema.brand("NonZeroInt64")) + .annotations({ + identifier: "NonZeroInt64", + title: "Non-Zero 64-bit Integer", + description: "A non-zero signed 64-bit integer (-9223372036854775808 to -1 or 1 to 9223372036854775807)" + }) /** * Type alias for NonZeroInt64 representing non-zero signed 64-bit integers. @@ -71,7 +73,7 @@ export const NonZeroInt64Schema = Schema.Union(NegInt64Schema, PosInt64Schema).a * @since 2.0.0 * @category model */ -export type NonZeroInt64 = typeof NonZeroInt64Schema.Type +export type NonZeroInt64 = typeof NonZeroInt64.Type /** * Smart constructor for creating NonZeroInt64 values. @@ -79,7 +81,7 @@ export type NonZeroInt64 = typeof NonZeroInt64Schema.Type * @since 2.0.0 * @category constructors */ -export const make = Schema.decodeSync(NonZeroInt64Schema) +export const make = Schema.decodeSync(NonZeroInt64) /** * Check if a value is a valid NonZeroInt64. @@ -87,7 +89,7 @@ export const make = Schema.decodeSync(NonZeroInt64Schema) * @since 2.0.0 * @category predicates */ -export const is = Schema.is(NonZeroInt64Schema) +export const is = Schema.is(NonZeroInt64) /** * Check if a NonZeroInt64 is positive. @@ -111,7 +113,16 @@ export const isNegative = (value: NonZeroInt64): boolean => value < 0n * @since 2.0.0 * @category transformation */ -export const abs = (value: NonZeroInt64): NonZeroInt64 => (value < 0n ? make(-value) : value) +export const abs = (value: NonZeroInt64): NonZeroInt64 => { + try { + return Schema.decodeSync(NonZeroInt64)(value < 0n ? -value : value) + } catch (cause) { + throw new NonZeroInt64Error({ + message: "Failed to get absolute value of NonZeroInt64", + cause + }) + } +} /** * Negate a NonZeroInt64. @@ -119,7 +130,16 @@ export const abs = (value: NonZeroInt64): NonZeroInt64 => (value < 0n ? make(-va * @since 2.0.0 * @category transformation */ -export const negate = (value: NonZeroInt64): NonZeroInt64 => make(-value) +export const negate = (value: NonZeroInt64): NonZeroInt64 => { + try { + return Schema.decodeSync(NonZeroInt64)(-value) + } catch (cause) { + throw new NonZeroInt64Error({ + message: "Failed to negate NonZeroInt64", + cause + }) + } +} /** * Compare two NonZeroInt64 values. @@ -142,12 +162,94 @@ export const compare = (a: NonZeroInt64, b: NonZeroInt64): -1 | 0 | 1 => { export const equals = (a: NonZeroInt64, b: NonZeroInt64): boolean => a === b /** - * Generate a random NonZeroInt64. + * FastCheck arbitrary for generating random NonZeroInt64 instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.oneof( +export const arbitrary = FastCheck.oneof( FastCheck.bigInt({ min: NEG_INT64_MIN, max: NEG_INT64_MAX }), FastCheck.bigInt({ min: POS_INT64_MIN, max: POS_INT64_MAX }) ) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse NonZeroInt64 from bigint. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBigInt = (value: bigint): NonZeroInt64 => { + try { + return Schema.decodeSync(NonZeroInt64)(value) + } catch (cause) { + throw new NonZeroInt64Error({ + message: "Failed to parse NonZeroInt64 from bigint", + cause + }) + } +} + +/** + * Encode NonZeroInt64 to bigint. + * + * @since 2.0.0 + * @category encoding + */ +export const toBigInt = (value: NonZeroInt64): bigint => { + try { + return Schema.encodeSync(NonZeroInt64)(value) + } catch (cause) { + throw new NonZeroInt64Error({ + message: "Failed to encode NonZeroInt64 to bigint", + cause + }) + } +} + +// ============================================================================ +// Either Namespace +// ============================================================================ + +/** + * Either-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category either + */ +export namespace Either { + /** + * Parse NonZeroInt64 from bigint with Either error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBigInt = (value: bigint): E.Either => + E.mapLeft( + Schema.decodeEither(NonZeroInt64)(value), + (cause) => + new NonZeroInt64Error({ + message: "Failed to parse NonZeroInt64 from bigint", + cause + }) + ) + + /** + * Encode NonZeroInt64 to bigint with Either error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBigInt = (value: NonZeroInt64): E.Either => + E.mapLeft( + Schema.encodeEither(NonZeroInt64)(value), + (cause) => + new NonZeroInt64Error({ + message: "Failed to encode NonZeroInt64 to bigint", + cause + }) + ) +} diff --git a/packages/evolution/src/Numeric.ts b/packages/evolution/src/Numeric.ts index 2140e34b..9d811d3f 100644 --- a/packages/evolution/src/Numeric.ts +++ b/packages/evolution/src/Numeric.ts @@ -1,22 +1,60 @@ -import { FastCheck, Schema } from "effect" - -export const UINT8_MIN = 0 -export const UINT8_MAX = 255 - -export const Uint8Schema = Schema.Number.pipe( +import { Data, FastCheck, Schema } from "effect" + +/** + * Error class for Numeric related operations. + * + * @since 2.0.0 + * @category errors + */ +export class NumericError extends Data.TaggedError("NumericError")<{ + message?: string + cause?: unknown +}> {} + +export const UINT8_MIN = 0n +export const UINT8_MAX = 255n + +/** + * Schema for 8-bit unsigned integers. + * + * @since 2.0.0 + * @category schemas + */ +export const Uint8Schema = Schema.BigIntFromSelf.pipe( Schema.filter((number) => Number.isInteger(number) && number >= UINT8_MIN && number <= UINT8_MAX), Schema.annotations({ identifier: "Uint8", + title: "8-bit Unsigned Integer", description: `An 8-bit unsigned integer (${UINT8_MIN} to ${UINT8_MAX})` }) ) +/** + * Type alias for 8-bit unsigned integers. + * + * @since 2.0.0 + * @category model + */ export type Uint8 = typeof Uint8Schema.Type -export const Uint8Generator = FastCheck.integer({ +/** + * Smart constructor for Uint8 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Uint8Make = Uint8Schema.make + +/** + * FastCheck arbitrary for generating random Uint8 instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const Uint8Generator = FastCheck.bigInt({ min: UINT8_MIN, max: UINT8_MAX -}).map((number) => Uint8Schema.make(number)) +}).map(Uint8Make) export const UINT16_MIN = 0 export const UINT16_MAX = 65535 @@ -25,16 +63,25 @@ export const Uint16Schema = Schema.Number.pipe( Schema.filter((number) => Number.isInteger(number) && number >= UINT16_MIN && number <= UINT16_MAX), Schema.annotations({ identifier: "Uint16", + title: "16-bit Unsigned Integer", description: `A 16-bit unsigned integer (${UINT16_MIN} to ${UINT16_MAX})` }) ) export type Uint16 = typeof Uint16Schema.Type +/** + * Smart constructor for Uint16 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Uint16Make = Uint16Schema.make + export const Uint16Generator = FastCheck.integer({ min: UINT16_MIN, max: UINT16_MAX -}).map((number) => Uint16Schema.make(number)) +}).map(Uint16Make) export const UINT32_MIN = 0 export const UINT32_MAX = 4294967295 @@ -43,16 +90,25 @@ export const Uint32Schema = Schema.Number.pipe( Schema.filter((number) => Number.isInteger(number) && number >= UINT32_MIN && number <= UINT32_MAX), Schema.annotations({ identifier: "Uint32", + title: "32-bit Unsigned Integer", description: `A 32-bit unsigned integer (${UINT32_MIN} to ${UINT32_MAX})` }) ) export type Uint32 = typeof Uint32Schema.Type +/** + * Smart constructor for Uint32 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Uint32Make = Uint32Schema.make + export const Uint32Generator = FastCheck.integer({ min: UINT32_MIN, max: UINT32_MAX -}).map((number) => Uint32Schema.make(number)) +}).map(Uint32Make) export const UINT64_MIN = 0n export const UINT64_MAX = 18446744073709551615n @@ -60,14 +116,24 @@ export const Uint64Schema = Schema.BigIntFromSelf.pipe( Schema.filter((bigint) => bigint >= UINT64_MIN && bigint <= UINT64_MAX), Schema.annotations({ identifier: "Uint64", + title: "64-bit Unsigned Integer", description: `A 64-bit unsigned integer (${UINT64_MIN} to ${UINT64_MAX})` }) ) export type Uint64 = typeof Uint64Schema.Type + +/** + * Smart constructor for Uint64 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Uint64Make = Uint64Schema.make + export const Uint64Generator = FastCheck.bigInt({ min: UINT64_MIN, max: UINT64_MAX -}).map((bigint) => Uint64Schema.make(bigint)) +}).map(Uint64Make) export const INT8_MIN = -128 export const INT8_MAX = 127 @@ -76,16 +142,25 @@ export const Int8 = Schema.Number.pipe( Schema.filter((number) => Number.isInteger(number) && number >= INT8_MIN && number <= INT8_MAX), Schema.annotations({ identifier: "Int8", + title: "8-bit Signed Integer", description: `An 8-bit signed integer (${INT8_MIN} to ${INT8_MAX})` }) ) export type Int8 = typeof Int8.Type +/** + * Smart constructor for Int8 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Int8Make = Int8.make + export const Int8Generator = FastCheck.integer({ min: INT8_MIN, max: INT8_MAX -}).map((number) => Int8.make(number)) +}).map(Int8Make) export const INT16_MIN = -32768 export const INT16_MAX = 32767 @@ -94,16 +169,25 @@ export const Int16 = Schema.Number.pipe( Schema.filter((number) => Number.isInteger(number) && number >= INT16_MIN && number <= INT16_MAX), Schema.annotations({ identifier: "Int16", + title: "16-bit Signed Integer", description: `A 16-bit signed integer (${INT16_MIN} to ${INT16_MAX})` }) ) export type Int16 = typeof Int16.Type +/** + * Smart constructor for Int16 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Int16Make = Int16.make + export const Int16Generator = FastCheck.integer({ min: INT16_MIN, max: INT16_MAX -}).map((number) => Int16.make(number)) +}).map(Int16Make) export const INT32_MIN = -2147483648 export const INT32_MAX = 2147483647 @@ -112,16 +196,25 @@ export const Int32 = Schema.Number.pipe( Schema.filter((number) => Number.isInteger(number) && number >= INT32_MIN && number <= INT32_MAX), Schema.annotations({ identifier: "Int32", + title: "32-bit Signed Integer", description: `A 32-bit signed integer (${INT32_MIN} to ${INT32_MAX})` }) ) export type Int32 = typeof Int32.Type +/** + * Smart constructor for Int32 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Int32Make = Int32.make + export const Int32Generator = FastCheck.integer({ min: INT32_MIN, max: INT32_MAX -}).map((number) => Int32.make(number)) +}).map(Int32Make) export const INT64_MIN = -9223372036854775808n export const INT64_MAX = 9223372036854775807n @@ -130,13 +223,22 @@ export const Int64 = Schema.BigIntFromSelf.pipe( Schema.filter((bigint) => bigint >= INT64_MIN && bigint <= INT64_MAX), Schema.annotations({ identifier: "Int64", + title: "64-bit Signed Integer", description: `A 64-bit signed integer (${INT64_MIN} to ${INT64_MAX})` }) ) export type Int64 = typeof Int64.Type +/** + * Smart constructor for Int64 that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const Int64Make = Int64.make + export const Int64Generator = FastCheck.bigInt({ min: INT64_MIN, max: INT64_MAX -}).map((bigint) => Int64.make(bigint)) +}).map(Int64Make) diff --git a/packages/evolution/src/OperationalCert.ts b/packages/evolution/src/OperationalCert.ts index 1e0437e5..76059775 100644 --- a/packages/evolution/src/OperationalCert.ts +++ b/packages/evolution/src/OperationalCert.ts @@ -1,8 +1,7 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as Ed25519Signature from "./Ed25519Signature.js" import * as KESVkey from "./KESVkey.js" import * as Numeric from "./Numeric.js" @@ -38,18 +37,6 @@ export class OperationalCert extends Schema.TaggedClass()("Oper sigma: Ed25519Signature.Ed25519Signature }) {} -/** - * Check if two OperationalCert instances are equal. - * - * @since 2.0.0 - * @category equality - */ -export const equals = (a: OperationalCert, b: OperationalCert): boolean => - KESVkey.equals(a.hotVkey, b.hotVkey) && - a.sequenceNumber === b.sequenceNumber && - a.kesPeriod === b.kesPeriod && - Ed25519Signature.equals(a.sigma, b.sigma) - /** * CDDL schema for OperationalCert. * operational_cert = [ @@ -73,13 +60,13 @@ export const FromCDDL = Schema.transformOrFail( { strict: true, encode: (toA) => - Effect.gen(function* () { + Eff.gen(function* () { const hotVkeyBytes = yield* ParseResult.encode(KESVkey.FromBytes)(toA.hotVkey) const sigmaBytes = yield* ParseResult.encode(Ed25519Signature.FromBytes)(toA.sigma) return [hotVkeyBytes, BigInt(toA.sequenceNumber), BigInt(toA.kesPeriod), sigmaBytes] as const }), decode: ([hotVkeyBytes, sequenceNumber, kesPeriod, sigmaBytes]) => - Effect.gen(function* () { + Eff.gen(function* () { const hotVkey = yield* ParseResult.decode(KESVkey.FromBytes)(hotVkeyBytes) const sigma = yield* ParseResult.decode(Ed25519Signature.FromBytes)(sigmaBytes) return yield* ParseResult.decode(OperationalCert)({ @@ -99,7 +86,7 @@ export const FromCDDL = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → OperationalCert @@ -111,17 +98,173 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → OperationalCert + FromCBORBytes(options) // Uint8Array → OperationalCert ) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - OperationalCertError - ) +/** + * Check if two OperationalCert instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: OperationalCert, b: OperationalCert): boolean => + KESVkey.equals(a.hotVkey, b.hotVkey) && + a.sequenceNumber === b.sequenceNumber && + a.kesPeriod === b.kesPeriod && + Ed25519Signature.equals(a.sigma, b.sigma) + +/** + * Check if the given value is a valid OperationalCert + * + * @since 2.0.0 + * @category predicates + */ +export const isOperationalCert = Schema.is(OperationalCert) + +/** + * FastCheck arbitrary for generating random OperationalCert instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.record({ + hotVkey: KESVkey.arbitrary, + sequenceNumber: FastCheck.bigUint(), + kesPeriod: FastCheck.bigUint(), + sigma: Ed25519Signature.arbitrary +}).map((props) => new OperationalCert(props)) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse OperationalCert from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): OperationalCert => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse OperationalCert from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): OperationalCert => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Encode OperationalCert to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (cert: OperationalCert, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(cert, options)) + +/** + * Encode OperationalCert to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (cert: OperationalCert, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(cert, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse OperationalCert from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options?: CBOR.CodecOptions + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new OperationalCertError({ + message: "Failed to parse OperationalCert from CBOR bytes", + cause + }) + ) + ) + + /** + * Parse OperationalCert from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = ( + hex: string, + options?: CBOR.CodecOptions + ): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new OperationalCertError({ + message: "Failed to parse OperationalCert from CBOR hex", + cause + }) + ) + ) + + /** + * Encode OperationalCert to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = ( + cert: OperationalCert, + options?: CBOR.CodecOptions + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(cert).pipe( + Eff.mapError( + (cause) => + new OperationalCertError({ + message: "Failed to encode OperationalCert to CBOR bytes", + cause + }) + ) + ) + + /** + * Encode OperationalCert to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = ( + cert: OperationalCert, + options?: CBOR.CodecOptions + ): Eff.Effect => + Schema.encode(FromCBORHex(options))(cert).pipe( + Eff.mapError( + (cause) => + new OperationalCertError({ + message: "Failed to encode OperationalCert to CBOR hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/PlutusV1.ts b/packages/evolution/src/PlutusV1.ts new file mode 100644 index 00000000..865bac1c --- /dev/null +++ b/packages/evolution/src/PlutusV1.ts @@ -0,0 +1,75 @@ +import { Data, FastCheck, Schema } from "effect" + +import * as CBOR from "./CBOR.js" + +/** + * Error class for PlutusV1 related operations. + * + * @since 2.0.0 + * @category errors + */ +export class PlutusV1Error extends Data.TaggedError("PlutusV1Error")<{ + message?: string + cause?: unknown +}> {} + +/** + * Plutus V1 script wrapper (raw bytes). + * + * @since 2.0.0 + * @category model + */ +export class PlutusV1 extends Schema.TaggedClass("PlutusV1")("PlutusV1", { + script: Schema.Uint8ArrayFromSelf +}) {} + +/** + * CDDL schema for PlutusV1 scripts as raw bytes. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = CBOR.ByteArray + +/** + * CDDL transformation schema for PlutusV1. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transform(CDDLSchema, PlutusV1, { + strict: true, + encode: (toI) => toI.script, + decode: (fromA) => new PlutusV1({ script: fromA }) +}) + +/** + * Smart constructor for PlutusV1. + * + * @since 2.0.0 + * @category constructors + */ +export const make = PlutusV1.make + +/** + * Check equality of two raw script byte arrays. + */ +const eqBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((v, i) => v === b[i]) + +/** + * Check if two PlutusV1 instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: PlutusV1, b: PlutusV1): boolean => eqBytes(a.script, b.script) + +/** + * FastCheck arbitrary for PlutusV1. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary: FastCheck.Arbitrary = FastCheck.uint8Array({ minLength: 1, maxLength: 512 }).map( + (script) => new PlutusV1({ script }) +) diff --git a/packages/evolution/src/PlutusV2.ts b/packages/evolution/src/PlutusV2.ts new file mode 100644 index 00000000..486387bd --- /dev/null +++ b/packages/evolution/src/PlutusV2.ts @@ -0,0 +1,75 @@ +import { Data, FastCheck, Schema } from "effect" + +import * as CBOR from "./CBOR.js" + +/** + * Error class for PlutusV2 related operations. + * + * @since 2.0.0 + * @category errors + */ +export class PlutusV2Error extends Data.TaggedError("PlutusV2Error")<{ + message?: string + cause?: unknown +}> {} + +/** + * Plutus V2 script wrapper (raw bytes). + * + * @since 2.0.0 + * @category model + */ +export class PlutusV2 extends Schema.TaggedClass("PlutusV2")("PlutusV2", { + script: Schema.Uint8ArrayFromSelf +}) {} + +/** + * CDDL schema for PlutusV2 scripts as raw bytes. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = CBOR.ByteArray + +/** + * CDDL transformation schema for PlutusV2. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transform(CDDLSchema, PlutusV2, { + strict: true, + encode: (toI) => toI.script, + decode: (fromA) => new PlutusV2({ script: fromA }) +}) + +/** + * Smart constructor for PlutusV2. + * + * @since 2.0.0 + * @category constructors + */ +export const make = PlutusV2.make + +/** + * Check equality of two raw script byte arrays. + */ +const eqBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((v, i) => v === b[i]) + +/** + * Check if two PlutusV2 instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: PlutusV2, b: PlutusV2): boolean => eqBytes(a.script, b.script) + +/** + * FastCheck arbitrary for PlutusV2. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary: FastCheck.Arbitrary = FastCheck.uint8Array({ minLength: 1, maxLength: 512 }).map( + (script) => new PlutusV2({ script }) +) diff --git a/packages/evolution/src/PlutusV3.ts b/packages/evolution/src/PlutusV3.ts new file mode 100644 index 00000000..ae8c83bf --- /dev/null +++ b/packages/evolution/src/PlutusV3.ts @@ -0,0 +1,75 @@ +import { Data, FastCheck, Schema } from "effect" + +import * as CBOR from "./CBOR.js" + +/** + * Error class for PlutusV3 related operations. + * + * @since 2.0.0 + * @category errors + */ +export class PlutusV3Error extends Data.TaggedError("PlutusV3Error")<{ + message?: string + cause?: unknown +}> {} + +/** + * Plutus V3 script wrapper (raw bytes). + * + * @since 2.0.0 + * @category model + */ +export class PlutusV3 extends Schema.TaggedClass("PlutusV3")("PlutusV3", { + script: Schema.Uint8ArrayFromSelf +}) {} + +/** + * CDDL schema for PlutusV3 scripts as raw bytes. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = CBOR.ByteArray + +/** + * CDDL transformation schema for PlutusV3. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transform(CDDLSchema, PlutusV3, { + strict: true, + encode: (toI) => toI.script, + decode: (fromA) => new PlutusV3({ script: fromA }) +}) + +/** + * Smart constructor for PlutusV3. + * + * @since 2.0.0 + * @category constructors + */ +export const make = PlutusV3.make + +/** + * Check equality of two raw script byte arrays. + */ +const eqBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((v, i) => v === b[i]) + +/** + * Check if two PlutusV3 instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: PlutusV3, b: PlutusV3): boolean => eqBytes(a.script, b.script) + +/** + * FastCheck arbitrary for PlutusV3. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary: FastCheck.Arbitrary = FastCheck.uint8Array({ minLength: 1, maxLength: 512 }).map( + (script) => new PlutusV3({ script }) +) diff --git a/packages/evolution/src/Pointer.ts b/packages/evolution/src/Pointer.ts index 4631c7cf..8da189ac 100644 --- a/packages/evolution/src/Pointer.ts +++ b/packages/evolution/src/Pointer.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { FastCheck, Schema } from "effect" import * as Natural from "./Natural.js" @@ -49,3 +49,23 @@ export const make = (slot: Natural.Natural, txIndex: Natural.Natural, certIndex: { disableValidation: true } ) } + +/** + * Check if two Pointer instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Pointer, b: Pointer): boolean => { + return a.slot === b.slot && a.txIndex === b.txIndex && a.certIndex === b.certIndex +} + +/** + * FastCheck arbitrary for generating random Pointer instances + * + * @since 2.0.0 + * @category generators + */ +export const arbitrary = FastCheck.tuple(Natural.arbitrary, Natural.arbitrary, Natural.arbitrary).map( + ([slot, txIndex, certIndex]) => make(slot, txIndex, certIndex) +) diff --git a/packages/evolution/src/PointerAddress.ts b/packages/evolution/src/PointerAddress.ts index fc239c76..1f979c52 100644 --- a/packages/evolution/src/PointerAddress.ts +++ b/packages/evolution/src/PointerAddress.ts @@ -1,7 +1,6 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" -import * as _Codec from "./Codec.js" import * as Credential from "./Credential.js" import * as KeyHash from "./KeyHash.js" import * as Natural from "./Natural.js" @@ -44,7 +43,7 @@ export class PointerAddress extends Schema.TaggedClass("PointerA export const FromBytes = Schema.transformOrFail(Schema.Uint8ArrayFromSelf, PointerAddress, { strict: true, encode: (toI, options, ast, toA) => - Effect.gen(function* () { + Eff.gen(function* () { const paymentBit = toA.paymentCredential._tag === "KeyHash" ? 0 : 1 const header = (0b01 << 6) | (0b0 << 5) | (paymentBit << 4) | (toA.networkId & 0b00001111) @@ -76,7 +75,7 @@ export const FromBytes = Schema.transformOrFail(Schema.Uint8ArrayFromSelf, Point return result }), decode: (_, __, ast, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const header = fromA[0] // Extract network ID from the lower 4 bits const networkId = header & 0b00001111 @@ -94,7 +93,7 @@ export const FromBytes = Schema.transformOrFail(Schema.Uint8ArrayFromSelf, Point : { _tag: "ScriptHash", - hash: yield* ParseResult.decode(ScriptHash.BytesSchema)(fromA.slice(1, 29)) + hash: yield* ParseResult.decode(ScriptHash.FromBytes)(fromA.slice(1, 29)) } // After the credential, we have 3 variable-length integers @@ -115,13 +114,19 @@ export const FromBytes = Schema.transformOrFail(Schema.Uint8ArrayFromSelf, Point paymentCredential, pointer: Pointer.make(slot, txIndex, certIndex) }) - }).pipe(Effect.catchTag("PointerAddressError", (e) => Effect.fail(new ParseResult.Type(ast, fromA, e.message)))) + }).pipe(Eff.catchTag("PointerAddressError", (e) => Eff.fail(new ParseResult.Type(ast, fromA, e.message)))) +}).annotations({ + identifier: "PointerAddress.FromBytes", + description: "Transforms raw bytes to PointerAddress" }) export const FromHex = Schema.compose( Bytes.FromHex, // string → Uint8Array FromBytes // Uint8Array → PointerAddress -) +).annotations({ + identifier: "PointerAddress.FromHex", + description: "Transforms raw hex string to PointerAddress" +}) /** * Encode a number as a variable length integer following the Cardano ledger specification @@ -130,7 +135,7 @@ export const FromHex = Schema.compose( * @category encoding/decoding */ export const encodeVariableLength = (natural: Natural.Natural) => - Effect.gen(function* () { + Eff.gen(function* () { // Handle the simple case: values less than 128 (0x80, binary 10000000) fit in a single byte // with no continuation bit needed if (natural < 128) { @@ -165,58 +170,70 @@ export const encodeVariableLength = (natural: Natural.Natural) => export const decodeVariableLength: ( bytes: Uint8Array, offset?: number | undefined -) => Effect.Effect<[Natural.Natural, number], PointerAddressError | ParseResult.ParseIssue> = Effect.fnUntraced( - function* (bytes, offset = 0) { - // The accumulated decoded value - let number = 0 - - // Count of bytes processed so far - let bytesRead = 0 - - // Multiplier for the current byte position (increases by powers of 128) - // Starts at 1 because the first 7 bits are multiplied by 1 - let multiplier = 1 - - while (true) { - // Check if we've reached the end of the buffer without finding a complete value - // This is a safeguard against buffer overruns - if (offset + bytesRead >= bytes.length) { - yield* new PointerAddressError({ - message: `Buffer overflow: not enough bytes to decode variable length integer at offset ${offset}` - }) - } - - // Read the current byte - const b = bytes[offset + bytesRead] - bytesRead++ - - // Extract value bits by masking with 0x7F (binary 01111111) - // This removes the high continuation bit and keeps only the 7 value bits - // Then multiply by the current position multiplier and add to accumulated value - number += (b & 0x7f) * multiplier - - // Check if this is the last byte by testing the high bit (0x80, binary 10000000) - // If the high bit is 0, we've reached the end of the encoded integer - if ((b & 0x80) === 0) { - // Return the decoded value and the count of bytes read - // const value = yield* Schema.decode(Natural.Natural)({ number }); - const value = yield* ParseResult.decode(Natural.Natural)(number) - return [value, bytesRead] as const - } - - // If the high bit is 1, we need to read more bytes - // Increase the multiplier for the next byte position (each position is worth 128 times more) - // This is because each byte holds 7 bits of value information - multiplier *= 128 - - // Continue reading bytes until we find one with the high bit set to 0 +) => Eff.Effect<[Natural.Natural, number], PointerAddressError | ParseResult.ParseIssue> = Eff.fnUntraced(function* ( + bytes: Uint8Array, + offset = 0 +) { + // The accumulated decoded value + let number = 0 + + // Count of bytes processed so far + let bytesRead = 0 + + // Multiplier for the current byte position (increases by powers of 128) + // Starts at 1 because the first 7 bits are multiplied by 1 + let multiplier = 1 + + while (true) { + // Check if we've reached the end of the buffer without finding a complete value + // This is a safeguard against buffer overruns + if (offset + bytesRead >= bytes.length) { + yield* new PointerAddressError({ + message: `Buffer overflow: not enough bytes to decode variable length integer at offset ${offset}` + }) + } + + // Read the current byte + const b = bytes[offset + bytesRead] + bytesRead++ + + // Extract value bits by masking with 0x7F (binary 01111111) + // This removes the high continuation bit and keeps only the 7 value bits + // Then multiply by the current position multiplier and add to accumulated value + number += (b & 0x7f) * multiplier + + // Check if this is the last byte by testing the high bit (0x80, binary 10000000) + // If the high bit is 0, we've reached the end of the encoded integer + if ((b & 0x80) === 0) { + // Return the decoded value and the count of bytes read + // const value = yield* Schema.decode(Natural.Natural)({ number }); + const value = yield* ParseResult.decode(Natural.Natural)(number) + return [value, bytesRead] as const } + + // If the high bit is 1, we need to read more bytes + // Increase the multiplier for the next byte position (each position is worth 128 times more) + // This is because each byte holds 7 bits of value information + multiplier *= 128 + + // Continue reading bytes until we find one with the high bit set to 0 } -) +}) /** - * Check if two PointerAddress instances are equal. + * Smart constructor for creating PointerAddress instances * + * @since 2.0.0 + * @category constructors + */ +export const make = (props: { + networkId: NetworkId.NetworkId + paymentCredential: Credential.Credential + pointer: Pointer.Pointer +}): PointerAddress => new PointerAddress(props) + +/** + * Check if two PointerAddress instances are equal. * * @since 2.0.0 * @category equality @@ -233,30 +250,109 @@ export const equals = (a: PointerAddress, b: PointerAddress): boolean => { } /** - * Generate a random PointerAddress. + * FastCheck arbitrary for generating random PointerAddress instances * * @since 2.0.0 - * @category generators + * @category testing */ -export const generator = FastCheck.tuple( - NetworkId.generator, - Credential.generator, - Natural.generator, - Natural.generator, - Natural.generator -).map( - ([networkId, paymentCredential, slot, txIndex, certIndex]) => - new PointerAddress({ - networkId, - paymentCredential, - pointer: Pointer.make(slot, txIndex, certIndex) - }) +export const arbitrary = FastCheck.tuple( + NetworkId.arbitrary, + Credential.arbitrary, + FastCheck.integer({ min: 1, max: 1000000 }), + FastCheck.integer({ min: 1, max: 1000000 }), + FastCheck.integer({ min: 1, max: 1000000 }) +).map(([networkId, paymentCredential, slot, txIndex, certIndex]) => + make({ + networkId, + paymentCredential, + pointer: Pointer.make(Natural.make(slot), Natural.make(txIndex), Natural.make(certIndex)) + }) ) -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - PointerAddressError -) +/** + * Effect namespace for PointerAddress operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert bytes to PointerAddress using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromBytes = (bytes: Uint8Array) => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => new PointerAddressError({ message: "Failed to decode from bytes", cause }) + ) + + /** + * Convert hex string to PointerAddress using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromHex = (hex: string) => + Eff.mapError( + Schema.decode(FromHex)(hex), + (cause) => new PointerAddressError({ message: "Failed to decode from hex", cause }) + ) + + /** + * Convert PointerAddress to bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toBytes = (address: PointerAddress) => + Eff.mapError( + Schema.encode(FromBytes)(address), + (cause) => new PointerAddressError({ message: "Failed to encode to bytes", cause }) + ) + + /** + * Convert PointerAddress to hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toHex = (address: PointerAddress) => + Eff.mapError( + Schema.encode(FromHex)(address), + (cause) => new PointerAddressError({ message: "Failed to encode to hex", cause }) + ) +} + +/** + * Convert bytes to PointerAddress (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromBytes = (bytes: Uint8Array): PointerAddress => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert hex string to PointerAddress (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromHex = (hex: string): PointerAddress => Eff.runSync(Effect.fromHex(hex)) + +/** + * Convert PointerAddress to bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toBytes = (address: PointerAddress): Uint8Array => Eff.runSync(Effect.toBytes(address)) + +/** + * Convert PointerAddress to hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toHex = (address: PointerAddress): string => Eff.runSync(Effect.toHex(address)) diff --git a/packages/evolution/src/PolicyId.ts b/packages/evolution/src/PolicyId.ts index a6b759b2..b290334f 100644 --- a/packages/evolution/src/PolicyId.ts +++ b/packages/evolution/src/PolicyId.ts @@ -1,6 +1,5 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" -import { createEncoders } from "./Codec.js" import * as Hash28 from "./Hash28.js" /** @@ -25,7 +24,7 @@ export class PolicyIdError extends Data.TaggedError("PolicyIdError")<{ * @since 2.0.0 * @category schemas */ -export const PolicyId = pipe(Hash28.HexSchema, Schema.brand("PolicyId")).annotations({ +export const PolicyId = Hash28.HexSchema.pipe(Schema.brand("PolicyId")).annotations({ identifier: "PolicyId" }) @@ -57,6 +56,14 @@ export const FromHex = Schema.compose( identifier: "PolicyId.Hex" }) +/** + * Smart constructor for PolicyId that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = PolicyId.make + /** * Check if two PolicyId instances are equal. * @@ -66,26 +73,136 @@ export const FromHex = Schema.compose( export const equals = (a: PolicyId, b: PolicyId): boolean => a === b /** - * Generate a random PolicyId. + * Check if the given value is a valid PolicyId + * + * @since 2.0.0 + * @category predicates + */ +export const isPolicyId = Schema.is(PolicyId) + +/** + * FastCheck arbitrary for generating random PolicyId instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ - minLength: Hash28.HASH28_BYTES_LENGTH, - maxLength: Hash28.HASH28_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const arbitrary = FastCheck.hexaString({ + minLength: Hash28.HEX_LENGTH, + maxLength: Hash28.HEX_LENGTH +}).map((hex) => hex as PolicyId) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for PolicyId encoding and decoding operations. + * Parse PolicyId from bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - PolicyIdError -) +export const fromBytes = (bytes: Uint8Array): PolicyId => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse PolicyId from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): PolicyId => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode PolicyId to bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (policyId: PolicyId): Uint8Array => Eff.runSync(Effect.toBytes(policyId)) + +/** + * Encode PolicyId to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (policyId: PolicyId): string => Eff.runSync(Effect.toHex(policyId)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse PolicyId from bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Schema.decode(FromBytes)(bytes).pipe( + Eff.mapError( + (cause) => + new PolicyIdError({ + message: "Failed to parse PolicyId from bytes", + cause + }) + ) + ) + + /** + * Parse PolicyId from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Schema.decode(FromHex)(hex).pipe( + Eff.mapError( + (cause) => + new PolicyIdError({ + message: "Failed to parse PolicyId from hex", + cause + }) + ) + ) + + /** + * Encode PolicyId to bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (policyId: PolicyId): Eff.Effect => + Schema.encode(FromBytes)(policyId).pipe( + Eff.mapError( + (cause) => + new PolicyIdError({ + message: "Failed to encode PolicyId to bytes", + cause + }) + ) + ) + + /** + * Encode PolicyId to hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = (policyId: PolicyId): Eff.Effect => + Schema.encode(FromHex)(policyId).pipe( + Eff.mapError( + (cause) => + new PolicyIdError({ + message: "Failed to encode PolicyId to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/PoolKeyHash.ts b/packages/evolution/src/PoolKeyHash.ts index 202371e5..022f8d36 100644 --- a/packages/evolution/src/PoolKeyHash.ts +++ b/packages/evolution/src/PoolKeyHash.ts @@ -1,6 +1,5 @@ -import { Data, FastCheck, pipe, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, pipe, Schema } from "effect" -import { createEncoders } from "./Codec.js" import * as Hash28 from "./Hash28.js" /** @@ -41,6 +40,14 @@ export const FromHex = Schema.compose( identifier: "PoolKeyHash.Hex" }) +/** + * Smart constructor for PoolKeyHash that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = PoolKeyHash.make + /** * Check if two PoolKeyHash instances are equal. * @@ -50,26 +57,108 @@ export const FromHex = Schema.compose( export const equals = (a: PoolKeyHash, b: PoolKeyHash): boolean => a === b /** - * Generate a random PoolKeyHash. + * FastCheck arbitrary for generating random PoolKeyHash instances. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.uint8Array({ - minLength: Hash28.HASH28_BYTES_LENGTH, - maxLength: Hash28.HASH28_BYTES_LENGTH -}).map((bytes) => Codec.Decode.bytes(bytes)) +export const arbitrary = FastCheck.uint8Array({ + minLength: Hash28.BYTES_LENGTH, + maxLength: Hash28.BYTES_LENGTH +}).map((bytes) => Eff.runSync(Effect.fromBytes(bytes))) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Codec utilities for PoolKeyHash encoding and decoding operations. + * Parse PoolKeyHash from raw bytes. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Codec = createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - PoolKeyHashError -) +export const fromBytes = (bytes: Uint8Array): PoolKeyHash => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse PoolKeyHash from hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): PoolKeyHash => Eff.runSync(Effect.fromHex(hex)) + +/** + * Encode PoolKeyHash to raw bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (poolKeyHash: PoolKeyHash): Uint8Array => Eff.runSync(Effect.toBytes(poolKeyHash)) + +/** + * Encode PoolKeyHash to hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (poolKeyHash: PoolKeyHash): string => poolKeyHash // Already a hex string + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse PoolKeyHash from raw bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new PoolKeyHashError({ + message: "Failed to parse PoolKeyHash from bytes", + cause + }) + ) + + /** + * Parse PoolKeyHash from hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Eff.mapError( + Schema.decode(PoolKeyHash)(hex), + (cause) => + new PoolKeyHashError({ + message: "Failed to parse PoolKeyHash from hex", + cause + }) + ) + + /** + * Encode PoolKeyHash to raw bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (poolKeyHash: PoolKeyHash): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(poolKeyHash), + (cause) => + new PoolKeyHashError({ + message: "Failed to encode PoolKeyHash to bytes", + cause + }) + ) +} diff --git a/packages/evolution/src/PoolMetadata.ts b/packages/evolution/src/PoolMetadata.ts index 06ddc28d..866811b6 100644 --- a/packages/evolution/src/PoolMetadata.ts +++ b/packages/evolution/src/PoolMetadata.ts @@ -1,8 +1,7 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as Url from "./Url.js" /** @@ -13,7 +12,7 @@ import * as Url from "./Url.js" */ export class PoolMetadataError extends Data.TaggedError("PoolMetadataError")<{ message?: string - reason?: "InvalidStructure" | "InvalidUrl" | "InvalidBytes" + cause?: unknown }> {} /** @@ -45,14 +44,45 @@ export const FromCDDL = Schema.transformOrFail( Schema.typeSchema(PoolMetadata), { strict: true, - encode: (poolMetadata) => Effect.succeed([poolMetadata.url, poolMetadata.hash] as const), + encode: (poolMetadata) => Eff.succeed([poolMetadata.url, poolMetadata.hash] as const), decode: ([urlText, hash]) => - Effect.gen(function* () { + Eff.gen(function* () { const url = yield* ParseResult.decode(Url.Url)(urlText) return new PoolMetadata({ url, hash }) }) } -) +).annotations({ + identifier: "PoolMetadata.FromCDDL", + description: "Transforms CBOR structure to PoolMetadata" +}) + +/** + * Smart constructor for creating PoolMetadata instances + * + * @since 2.0.0 + * @category constructors + */ +export const make = (props: { url: Url.Url; hash: Uint8Array }): PoolMetadata => new PoolMetadata(props) + +/** + * Check if two PoolMetadata instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: PoolMetadata, b: PoolMetadata): boolean => + a.url === b.url && a.hash.every((byte, index) => byte === b.hash[index]) + +/** + * FastCheck arbitrary for generating random PoolMetadata instances + * + * @since 2.0.0 + * @category testing + */ +export const arbitrary = FastCheck.record({ + url: Url.arbitrary, + hash: FastCheck.uint8Array({ minLength: 32, maxLength: 32 }) +}).map(make) /** * CBOR bytes transformation schema for PoolMetadata. @@ -61,11 +91,14 @@ export const FromCDDL = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → PoolMetadata - ) + ).annotations({ + identifier: "PoolMetadata.FromCBORBytes", + description: "Transforms CBOR bytes to PoolMetadata" + }) /** * CBOR hex transformation schema for PoolMetadata. @@ -74,17 +107,103 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → PoolMetadata - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - PoolMetadataError - ) + FromCBORBytes(options) // Uint8Array → PoolMetadata + ).annotations({ + identifier: "PoolMetadata.FromCBORHex", + description: "Transforms CBOR hex string to PoolMetadata" + }) + +/** + * Effect namespace for PoolMetadata operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert CBOR bytes to PoolMetadata using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (cause) => new PoolMetadataError({ message: "Failed to decode from CBOR bytes", cause }) + ) + + /** + * Convert CBOR hex string to PoolMetadata using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (cause) => new PoolMetadataError({ message: "Failed to decode from CBOR hex", cause }) + ) + + /** + * Convert PoolMetadata to CBOR bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORBytes = (metadata: PoolMetadata, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(metadata), + (cause) => new PoolMetadataError({ message: "Failed to encode to CBOR bytes", cause }) + ) + + /** + * Convert PoolMetadata to CBOR hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORHex = (metadata: PoolMetadata, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(metadata), + (cause) => new PoolMetadataError({ message: "Failed to encode to CBOR hex", cause }) + ) +} + +/** + * Convert CBOR bytes to PoolMetadata (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): PoolMetadata => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Convert CBOR hex string to PoolMetadata (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): PoolMetadata => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Convert PoolMetadata to CBOR bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORBytes = (metadata: PoolMetadata, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(metadata, options)) + +/** + * Convert PoolMetadata to CBOR hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORHex = (metadata: PoolMetadata, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(metadata, options)) diff --git a/packages/evolution/src/PoolParams.ts b/packages/evolution/src/PoolParams.ts index a46ab334..22206f71 100644 --- a/packages/evolution/src/PoolParams.ts +++ b/packages/evolution/src/PoolParams.ts @@ -1,8 +1,7 @@ -import { BigDecimal, Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { BigDecimal, Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as Coin from "./Coin.js" import * as KeyHash from "./KeyHash.js" import * as NetworkId from "./NetworkId.js" @@ -47,8 +46,8 @@ export class PoolParamsError extends Data.TaggedError("PoolParamsError")<{ export class PoolParams extends Schema.TaggedClass()("PoolParams", { operator: PoolKeyHash.PoolKeyHash, vrfKeyhash: VrfKeyHash.VrfKeyHash, - pledge: Coin.CoinSchema, - cost: Coin.CoinSchema, + pledge: Coin.Coin, + cost: Coin.Coin, margin: UnitInterval.UnitInterval, rewardAccount: RewardAccount.RewardAccount, poolOwners: Schema.Array(KeyHash.KeyHash), @@ -56,22 +55,19 @@ export class PoolParams extends Schema.TaggedClass()("PoolParams", { poolMetadata: Schema.optionalWith(PoolMetadata.PoolMetadata, { nullable: true }) -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: "PoolParams", - operator: this.operator, - vrfKeyhash: this.vrfKeyhash, - pledge: this.pledge, - cost: this.cost, - margin: this.margin, - rewardAccount: this.rewardAccount, - poolOwners: this.poolOwners, - relays: this.relays, - poolMetadata: this.poolMetadata - } - } -} +}) {} + +export const CDDLSchema = Schema.Tuple( + CBOR.ByteArray, // operator (pool_keyhash as bytes) + CBOR.ByteArray, // vrf_keyhash (as bytes) + CBOR.Integer, // pledge (coin) + CBOR.Integer, // cost (coin) + UnitInterval.CDDLSchema, // margin using UnitInterval CDDL schema + CBOR.ByteArray, // reward_account (bytes) + Schema.Array(CBOR.ByteArray), // pool_owners (set as bytes array) + Schema.Array(Schema.encodedSchema(Relay.FromCDDL)), // relays using Relay CDDL schema + Schema.NullOr(Schema.encodedSchema(PoolMetadata.FromCDDL)) // pool_metadata using PoolMetadata CDDL schema +) /** * CDDL schema for PoolParams. @@ -93,95 +89,82 @@ export class PoolParams extends Schema.TaggedClass()("PoolParams", { * @since 2.0.0 * @category schemas */ -export const PoolParamsCDDLSchema = Schema.transformOrFail( - Schema.Tuple( - Schema.Uint8ArrayFromSelf, // operator (pool_keyhash as bytes) - Schema.Uint8ArrayFromSelf, // vrf_keyhash (as bytes) - Schema.BigIntFromSelf, // pledge (coin) - Schema.BigIntFromSelf, // cost (coin) - Schema.encodedSchema(UnitInterval.FromCDDL), // margin using UnitInterval CDDL schema - Schema.Uint8ArrayFromSelf, // reward_account (bytes) - Schema.Array(Schema.Uint8ArrayFromSelf), // pool_owners (set as bytes array) - Schema.Array(Schema.encodedSchema(Relay.FromCDDL)), // relays using Relay CDDL schema - Schema.NullOr(Schema.encodedSchema(PoolMetadata.FromCDDL)) // pool_metadata using PoolMetadata CDDL schema - ), - Schema.typeSchema(PoolParams), - { - strict: true, - encode: (toA) => - Effect.gen(function* () { - const operatorBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.operator) - const vrfKeyhashBytes = yield* ParseResult.encode(VrfKeyHash.FromBytes)(toA.vrfKeyhash) - const marginEncoded = yield* ParseResult.encode(UnitInterval.FromCDDL)(toA.margin) - const rewardAccountBytes = yield* ParseResult.encode(RewardAccount.FromBytes)(toA.rewardAccount) - - const poolOwnersBytes = yield* Effect.all( - toA.poolOwners.map((owner) => ParseResult.encode(KeyHash.FromBytes)(owner)) - ) - - const relaysEncoded = yield* Effect.all(toA.relays.map((relay) => ParseResult.encode(Relay.FromCDDL)(relay))) - - const poolMetadataEncoded = toA.poolMetadata - ? yield* ParseResult.encode(PoolMetadata.FromCDDL)(toA.poolMetadata) - : null - - return yield* Effect.succeed([ - operatorBytes, - vrfKeyhashBytes, - toA.pledge, - toA.cost, - marginEncoded, - rewardAccountBytes, - poolOwnersBytes, - relaysEncoded, - poolMetadataEncoded - ] as const) - }), - decode: ([ - operatorBytes, - vrfKeyhashBytes, - pledge, - cost, - marginEncoded, - rewardAccountBytes, - poolOwnersBytes, - relaysEncoded, - poolMetadataEncoded - ]) => - Effect.gen(function* () { - const operator = yield* ParseResult.decode(PoolKeyHash.FromBytes)(operatorBytes) - const vrfKeyhash = yield* ParseResult.decode(VrfKeyHash.FromBytes)(vrfKeyhashBytes) - const margin = yield* ParseResult.decode(UnitInterval.FromCDDL)(marginEncoded) - const rewardAccount = yield* ParseResult.decode(RewardAccount.FromBytes)(rewardAccountBytes) - - const poolOwners = yield* Effect.all( - poolOwnersBytes.map((ownerBytes) => ParseResult.decode(KeyHash.FromBytes)(ownerBytes)) - ) - - const relays = yield* Effect.all( - relaysEncoded.map((relayEncoded) => ParseResult.decode(Relay.FromCDDL)(relayEncoded)) - ) - - const poolMetadata = poolMetadataEncoded - ? yield* ParseResult.decode(PoolMetadata.FromCDDL)(poolMetadataEncoded) - : undefined - - return yield* Effect.succeed( - new PoolParams({ - operator, - vrfKeyhash, - pledge, - cost, - margin, - rewardAccount, - poolOwners, - relays, - poolMetadata - }) - ) - }) - } -) +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(PoolParams), { + strict: true, + encode: (toA) => + Eff.gen(function* () { + const operatorBytes = yield* ParseResult.encode(PoolKeyHash.FromBytes)(toA.operator) + const vrfKeyhashBytes = yield* ParseResult.encode(VrfKeyHash.FromBytes)(toA.vrfKeyhash) + + const marginEncoded = yield* ParseResult.encode(UnitInterval.FromCDDL)(toA.margin) + const rewardAccountBytes = yield* ParseResult.encode(RewardAccount.FromBytes)(toA.rewardAccount) + + const poolOwnersBytes = yield* Eff.all( + toA.poolOwners.map((owner) => ParseResult.encode(KeyHash.FromBytes)(owner)) + ) + + const relaysEncoded = yield* Eff.all(toA.relays.map((relay) => ParseResult.encode(Relay.FromCDDL)(relay))) + + const poolMetadataEncoded = toA.poolMetadata + ? yield* ParseResult.encode(PoolMetadata.FromCDDL)(toA.poolMetadata) + : null + + return [ + operatorBytes, + vrfKeyhashBytes, + toA.pledge, + toA.cost, + marginEncoded, + rewardAccountBytes, + poolOwnersBytes, + relaysEncoded, + poolMetadataEncoded + ] as const + }), + decode: ([ + operatorBytes, + vrfKeyhashBytes, + pledge, + cost, + marginEncoded, + rewardAccountBytes, + poolOwnersBytes, + relaysEncoded, + poolMetadataEncoded + ]) => + Eff.gen(function* () { + const operator = yield* ParseResult.decode(PoolKeyHash.FromBytes)(operatorBytes) + const vrfKeyhash = yield* ParseResult.decode(VrfKeyHash.FromBytes)(vrfKeyhashBytes) + const margin = yield* ParseResult.decode(UnitInterval.FromCDDL)(marginEncoded) + const rewardAccount = yield* ParseResult.decode(RewardAccount.FromBytes)(rewardAccountBytes) + + const poolOwners = yield* Eff.all( + poolOwnersBytes.map((ownerBytes) => ParseResult.decode(KeyHash.FromBytes)(ownerBytes)) + ) + + const relays = yield* Eff.all( + relaysEncoded.map((relayEncoded) => ParseResult.decode(Relay.FromCDDL)(relayEncoded)) + ) + + const poolMetadata = poolMetadataEncoded + ? yield* ParseResult.decode(PoolMetadata.FromCDDL)(poolMetadataEncoded) + : undefined + + return yield* Eff.succeed( + new PoolParams({ + operator, + vrfKeyhash, + pledge, + cost, + margin, + rewardAccount, + poolOwners, + relays, + poolMetadata + }) + ) + }) +}) /** * CBOR bytes transformation schema for PoolParams. @@ -189,10 +172,10 @@ export const PoolParamsCDDLSchema = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR - PoolParamsCDDLSchema // CBOR → PoolParams + FromCDDL // CBOR → PoolParams ) /** @@ -201,7 +184,7 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array FromBytes(options) // Uint8Array → PoolParams @@ -305,17 +288,17 @@ export const hasValidMargin = (params: PoolParams): boolean => params.margin.numerator <= params.margin.denominator && params.margin.denominator > 0n /** - * Generate a random PoolParams. + * FastCheck arbitrary for generating random PoolParams instances for testing. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.record({ - operator: PoolKeyHash.generator, - vrfKeyhash: VrfKeyHash.generator, +export const arbitrary = FastCheck.record({ + operator: PoolKeyHash.arbitrary, + vrfKeyhash: VrfKeyHash.arbitrary, pledge: FastCheck.bigInt({ min: 0n, max: 1000000000000n }), cost: FastCheck.bigInt({ min: 340000000n, max: 1000000000n }), - margin: UnitInterval.generator, + margin: UnitInterval.arbitrary, rewardAccount: FastCheck.constant( new RewardAccount.RewardAccount({ networkId: NetworkId.make(1), @@ -325,21 +308,144 @@ export const generator = FastCheck.record({ } }) ), - poolOwners: FastCheck.array(KeyHash.generator, { + poolOwners: FastCheck.array(KeyHash.arbitrary, { minLength: 1, maxLength: 5 }), - relays: FastCheck.array(Relay.generator, { minLength: 0, maxLength: 3 }), + relays: FastCheck.array(Relay.arbitrary, { minLength: 0, maxLength: 3 }), poolMetadata: FastCheck.option(FastCheck.constant(undefined), { nil: undefined }) }).map((params) => new PoolParams(params)) -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - PoolParamsError - ) +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse PoolParams from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): PoolParams => + Eff.runSync(Effect.fromBytes(bytes, options)) + +/** + * Parse PoolParams from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string, options?: CBOR.CodecOptions): PoolParams => + Eff.runSync(Effect.fromHex(hex, options)) + +/** + * Encode PoolParams to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (poolParams: PoolParams, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toBytes(poolParams, options)) + +/** + * Encode PoolParams to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (poolParams: PoolParams, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toHex(poolParams, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse PoolParams from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new PoolParamsError({ + message: "Failed to parse PoolParams from bytes", + cause + }) + ) + ) + + /** + * Parse PoolParams from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new PoolParamsError({ + message: "Failed to parse PoolParams from hex", + cause + }) + ) + ) + + /** + * Encode PoolParams to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = ( + poolParams: PoolParams, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromBytes(options))(poolParams).pipe( + Eff.mapError( + (cause) => + new PoolParamsError({ + message: "Failed to encode PoolParams to bytes", + cause + }) + ) + ) + + /** + * Encode PoolParams to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toHex = ( + poolParams: PoolParams, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromHex(options))(poolParams).pipe( + Eff.mapError( + (cause) => + new PoolParamsError({ + message: "Failed to encode PoolParams to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/Port.ts b/packages/evolution/src/Port.ts index 7947cfe4..75b4cd15 100644 --- a/packages/evolution/src/Port.ts +++ b/packages/evolution/src/Port.ts @@ -95,9 +95,9 @@ export const isDynamic = (port: Port): boolean => port >= 49152 && port <= 65535 * Generate a random Port. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = Numeric.Uint16Generator +export const arbitrary = Numeric.Uint16Generator /** * Synchronous encoding/decoding utilities. diff --git a/packages/evolution/src/PositiveCoin.ts b/packages/evolution/src/PositiveCoin.ts index 954d2ebb..9d8dd463 100644 --- a/packages/evolution/src/PositiveCoin.ts +++ b/packages/evolution/src/PositiveCoin.ts @@ -1,4 +1,4 @@ -import { Data, FastCheck, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Coin from "./Coin.js" @@ -10,7 +10,7 @@ import * as Coin from "./Coin.js" */ export class PositiveCoinError extends Data.TaggedError("PositiveCoinError")<{ message?: string - reason?: "InvalidAmount" | "ZeroAmount" | "ExceedsMaxValue" + cause?: unknown }> {} /** @@ -41,7 +41,9 @@ export const PositiveCoinSchema = Schema.BigIntFromSelf.pipe( ).annotations({ message: (issue) => `PositiveCoin must be between ${MIN_POSITIVE_COIN_VALUE} and ${MAX_POSITIVE_COIN_VALUE}, but got ${issue.actual}`, - identifier: "PositiveCoin" + identifier: "PositiveCoin", + title: "Positive Coin Amount", + description: "A positive amount of native assets (1 to maxWord64)" }) /** @@ -55,11 +57,12 @@ export type PositiveCoin = typeof PositiveCoinSchema.Type /** * Smart constructor for creating PositiveCoin values. + * Uses the built-in .make property for branded schemas. * * @since 2.0.0 * @category constructors */ -export const make = (value: bigint): PositiveCoin => PositiveCoinSchema.make(value) +export const make = PositiveCoinSchema.make /** * Create a PositiveCoin from a regular Coin, throwing if the value is zero. @@ -70,8 +73,7 @@ export const make = (value: bigint): PositiveCoin => PositiveCoinSchema.make(val export const fromCoin = (coin: Coin.Coin): PositiveCoin => { if (coin === 0n) { throw new PositiveCoinError({ - message: "Cannot create PositiveCoin from zero coin amount", - reason: "ZeroAmount" + message: "Cannot create PositiveCoin from zero coin amount" }) } return make(coin) @@ -103,11 +105,10 @@ export const add = (a: PositiveCoin, b: PositiveCoin): PositiveCoin => { const result = a + b if (result > MAX_POSITIVE_COIN_VALUE) { throw new PositiveCoinError({ - message: `Addition overflow: ${a} + ${b} exceeds maximum positive coin value`, - reason: "ExceedsMaxValue" + message: `Addition overflow: ${a} + ${b} exceeds maximum positive coin value` }) } - return result + return make(result) } /** @@ -121,11 +122,10 @@ export const subtract = (a: PositiveCoin, b: PositiveCoin): PositiveCoin => { const result = a - b if (result <= 0n) { throw new PositiveCoinError({ - message: `Subtraction underflow: ${a} - ${b} results in non-positive value`, - reason: "ZeroAmount" + message: `Subtraction underflow: ${a} - ${b} results in non-positive value` }) } - return result + return make(result) } /** @@ -149,40 +149,80 @@ export const compare = (a: PositiveCoin, b: PositiveCoin): -1 | 0 | 1 => { export const equals = (a: PositiveCoin, b: PositiveCoin): boolean => a === b /** - * Generate a random PositiveCoin value. + * FastCheck arbitrary for generating random PositiveCoin values. * * @since 2.0.0 - * @category generators + * @category arbitrary */ -export const generator = FastCheck.bigInt({ +export const arbitrary = FastCheck.bigInt({ min: MIN_POSITIVE_COIN_VALUE, max: MAX_POSITIVE_COIN_VALUE -}) +}).map(make) + +// ============================================================================ +// Root Functions +// ============================================================================ /** - * Synchronous encoding/decoding utilities. + * Parse PositiveCoin from bigint value. * * @since 2.0.0 - * @category encoding/decoding + * @category parsing */ -export const Encode = { - sync: Schema.encodeSync(PositiveCoinSchema) -} +export const fromBigInt = (value: bigint): PositiveCoin => Eff.runSync(Effect.fromBigInt(value)) -export const Decode = { - sync: Schema.decodeUnknownSync(PositiveCoinSchema) -} +/** + * Convert PositiveCoin to bigint value. + * + * @since 2.0.0 + * @category encoding + */ +export const toBigInt = (positiveCoin: PositiveCoin): bigint => Eff.runSync(Effect.toBigInt(positiveCoin)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ /** - * Either encoding/decoding utilities. + * Effect-based error handling variants for functions that can fail. * * @since 2.0.0 - * @category encoding/decoding + * @category effect */ -export const EncodeEither = { - either: Schema.encodeEither(PositiveCoinSchema) -} +export namespace Effect { + /** + * Parse PositiveCoin from bigint value with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBigInt = (value: bigint): Eff.Effect => + Schema.decode(PositiveCoinSchema)(value).pipe( + Eff.mapError( + (cause) => + new PositiveCoinError({ + message: "Failed to parse PositiveCoin from bigint", + cause + }) + ) + ) -export const DecodeEither = { - either: Schema.decodeUnknownEither(PositiveCoinSchema) + /** + * Convert PositiveCoin to bigint value with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBigInt = (positiveCoin: PositiveCoin): Eff.Effect => + Schema.encode(PositiveCoinSchema)(positiveCoin).pipe( + Eff.mapError( + (cause) => + new PositiveCoinError({ + message: "Failed to encode PositiveCoin to bigint", + cause + }) + ) + ) } + +// ============================================================================ diff --git a/packages/evolution/src/PrivateKey.ts b/packages/evolution/src/PrivateKey.ts new file mode 100644 index 00000000..e7168692 --- /dev/null +++ b/packages/evolution/src/PrivateKey.ts @@ -0,0 +1,425 @@ +import { bech32 } from "@scure/base" +import * as BIP32 from "@scure/bip32" +import * as BIP39 from "@scure/bip39" +import { wordlist } from "@scure/bip39/wordlists/english" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" +import sodium from "libsodium-wrappers-sumo" + +import * as Bytes32 from "./Bytes32.js" +import * as Bytes64 from "./Bytes64.js" +import * as VKey from "./VKey.js" + +/** + * Error class for PrivateKey related operations. + * + * @since 2.0.0 + * @category errors + */ +export class PrivateKeyError extends Data.TaggedError("PrivateKeyError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Schema for PrivateKey representing an Ed25519 private key. + * Supports both standard 32-byte and CIP-0003 extended 64-byte formats. + * Follows the Conway-era CDDL specification with CIP-0003 compatibility. + * + * @since 2.0.0 + * @category schemas + */ +export const PrivateKey = Schema.Union(Bytes32.HexSchema, Bytes64.HexSchema) + .pipe(Schema.brand("PrivateKey")) + .annotations({ + identifier: "PrivateKey", + description: "An Ed25519 private key supporting both standard 32-byte and CIP-0003 extended 64-byte formats" + }) + +export type PrivateKey = typeof PrivateKey.Type + +export const FromBytes = Schema.compose(Schema.Union(Bytes32.FromBytes, Bytes64.FromBytes), PrivateKey).annotations({ + identifier: "PrivateKey.FromBytes", + description: "Transforms raw bytes (Uint8Array) to PrivateKey hex string" +}) + +export const FromHex = PrivateKey + +export const FromBech32 = Schema.transformOrFail(Schema.String, PrivateKey, { + strict: true, + encode: (_, __, ___, toA) => + Eff.gen(function* () { + const privateKeyBytes = yield* ParseResult.encode(FromBytes)(toA) + const words = bech32.toWords(privateKeyBytes) + return bech32.encode("ed25519e_sk", words, 1023) + }), + decode: (fromA, _, ast) => + Eff.gen(function* () { + const { prefix, words } = yield* ParseResult.try({ + try: () => bech32.decode(fromA as any, 1023), + catch: (error) => new ParseResult.Type(ast, fromA, `Failed to decode Bech32: ${(error as Error).message}`) + }) + if (prefix !== "ed25519e_sk") { + throw new ParseResult.Type(ast, fromA, `Expected ed25519e_sk prefix, got ${prefix}`) + } + const decoded = bech32.fromWords(words) + return yield* ParseResult.decode(FromBytes)(decoded) + }) +}).annotations({ + identifier: "PrivateKey.FromBech32", + description: "Transforms Bech32 string (ed25519e_sk1...) to PrivateKey" +}) + +/** + * Smart constructor for PrivateKey that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = PrivateKey.make + +/** + * Check if two PrivateKey instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: PrivateKey, b: PrivateKey): boolean => a === b + +/** + * FastCheck arbitrary for generating random PrivateKey instances. + * Generates 32-byte private keys. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.uint8Array({ minLength: 32, maxLength: 32 }).map((bytes) => + Eff.runSync(Effect.fromBytes(bytes)) +) + +// ============================================================================ +// Parsing Functions +// ============================================================================ + +/** + * Parse a PrivateKey from raw bytes. + * Supports both 32-byte and 64-byte private keys. + * + * @since 2.0.0 + * @category parsing + */ +export const fromBytes = (bytes: Uint8Array): PrivateKey => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Parse a PrivateKey from a hex string. + * Supports both 32-byte (64 chars) and 64-byte (128 chars) hex strings. + * + * @since 2.0.0 + * @category parsing + */ +export const fromHex = (hex: string): PrivateKey => Eff.runSync(Effect.fromHex(hex)) + +/** + * Parse a PrivateKey from a Bech32 string. + * Expected format: ed25519e_sk1... + * + * @since 2.0.0 + * @category parsing + */ +export const fromBech32 = (bech32: string): PrivateKey => Eff.runSync(Effect.fromBech32(bech32)) + +// ============================================================================ +// Encoding Functions +// ============================================================================ + +/** + * Convert a PrivateKey to raw bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toBytes = (privateKey: PrivateKey): Uint8Array => Eff.runSync(Effect.toBytes(privateKey)) + +/** + * Convert a PrivateKey to a hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toHex = (privateKey: PrivateKey): string => privateKey // Already a hex string + +/** + * Convert a PrivateKey to a Bech32 string. + * Format: ed25519e_sk1... + * + * @since 2.0.0 + * @category encoding + */ +export const toBech32 = (privateKey: PrivateKey): string => Eff.runSync(Effect.toBech32(privateKey)) + +// ============================================================================ +// Factory Functions +// ============================================================================ + +/** + * Generate a random 32-byte Ed25519 private key. + * Compatible with CML.PrivateKey.generate_ed25519(). + * + * @since 2.0.0 + * @category generators + */ +export const generate = () => sodium.randombytes_buf(32) + +/** + * Generate a random 64-byte extended Ed25519 private key. + * Compatible with CML.PrivateKey.generate_ed25519extended(). + * + * @since 2.0.0 + * @category generators + */ +export const generateExtended = () => sodium.randombytes_buf(64) + +/** + * Derive the public key (VKey) from a private key. + * Compatible with CML privateKey.to_public(). + * + * @since 2.0.0 + * @category cryptography + */ +export const toPublicKey = (privateKey: PrivateKey): VKey.VKey => VKey.fromPrivateKey(privateKey) + +/** + * Generate a new mnemonic phrase using BIP39. + * + * @since 2.0.0 + * @category bip39 + */ +export const generateMnemonic = (strength: 128 | 160 | 192 | 224 | 256 = 256): string => + BIP39.generateMnemonic(wordlist, strength) + +/** + * Validate a mnemonic phrase using BIP39. + * + * @since 2.0.0 + * @category bip39 + */ +export const validateMnemonic = (mnemonic: string): boolean => BIP39.validateMnemonic(mnemonic, wordlist) + +/** + * Create a PrivateKey from a mnemonic phrase (sync version that throws PrivateKeyError). + * All errors are normalized to PrivateKeyError with contextual information. + * + * @since 2.0.0 + * @category bip39 + */ +export const fromMnemonic = (mnemonic: string, password?: string): PrivateKey => + Eff.runSync(Effect.fromMnemonic(mnemonic, password)) + +/** + * Derive a child private key using BIP32 path (sync version that throws PrivateKeyError). + * All errors are normalized to PrivateKeyError with contextual information. + * + * @since 2.0.0 + * @category bip32 + */ +export const derive = (privateKey: PrivateKey, path: string): PrivateKey => Eff.runSync(Effect.derive(privateKey, path)) + +// ============================================================================ +// Cryptographic Operations +// ============================================================================ + +/** + * Sign a message using Ed25519 (sync version that throws PrivateKeyError). + * All errors are normalized to PrivateKeyError with contextual information. + * For extended keys (64 bytes), uses CML-compatible Ed25519-BIP32 signing. + * For normal keys (32 bytes), uses standard Ed25519 signing. + * + * @since 2.0.0 + * @category cryptography + */ +export const sign = (privateKey: PrivateKey, message: Uint8Array): Uint8Array => + Eff.runSync(Effect.sign(privateKey, message)) + +/** + * Cardano BIP44 derivation path utilities. + * + * @since 2.0.0 + * @category cardano + */ +export const CardanoPath = { + /** + * Create a Cardano BIP44 derivation path. + * Standard path: m/1852'/1815'/account'/role/index + */ + create: (account: number = 0, role: 0 | 2 = 0, index: number = 0) => `m/1852'/${1815}'/${account}'/${role}/${index}`, + + /** + * Payment key path (role = 0) + */ + payment: (account: number = 0, index: number = 0) => CardanoPath.create(account, 0, index), + + /** + * Stake key path (role = 2) + */ + stake: (account: number = 0, index: number = 0) => CardanoPath.create(account, 2, index) +} + +// ============================================================================ +// Effect Namespace - Effect-based Error Handling +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * Returns Effect for composable error handling. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse a PrivateKey from raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBytes = (bytes: Uint8Array): Eff.Effect => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => + new PrivateKeyError({ + message: `Failed to parse PrivateKey from bytes`, + cause + }) + ) + + /** + * Parse a PrivateKey from a hex string using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromHex = (hex: string): Eff.Effect => + Eff.mapError( + Schema.decode(FromHex)(hex), + (cause) => + new PrivateKeyError({ + message: "Failed to parse PrivateKey from hex", + cause + }) + ) + + /** + * Parse a PrivateKey from a Bech32 string using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromBech32 = (bech32: string): Eff.Effect => + Eff.mapError( + Schema.decode(FromBech32)(bech32), + (cause) => + new PrivateKeyError({ + message: "Failed to parse PrivateKey from Bech32", + cause + }) + ) + + /** + * Convert a PrivateKey to raw bytes using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBytes = (privateKey: PrivateKey): Eff.Effect => + Eff.mapError( + Schema.encode(FromBytes)(privateKey), + (cause) => + new PrivateKeyError({ + message: "Failed to encode PrivateKey to bytes", + cause + }) + ) + + /** + * Convert a PrivateKey to a Bech32 string using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toBech32 = (privateKey: PrivateKey): Eff.Effect => + Eff.mapError( + Schema.encode(FromBech32)(privateKey), + (cause) => + new PrivateKeyError({ + message: "Failed to encode PrivateKey to Bech32", + cause + }) + ) + + /** + * Create a PrivateKey from a mnemonic phrase using Effect error handling. + * + * @since 2.0.0 + * @category bip39 + */ + export const fromMnemonic = (mnemonic: string, password?: string): Eff.Effect => + Eff.gen(function* () { + if (!validateMnemonic(mnemonic)) { + return yield* Eff.fail(new PrivateKeyError({ message: "Invalid mnemonic phrase" })) + } + const seed = BIP39.mnemonicToSeedSync(mnemonic, password || "") + const hdKey = BIP32.HDKey.fromMasterSeed(seed) + if (!hdKey.privateKey) { + return yield* Eff.fail(new PrivateKeyError({ message: "No private key in HD key" })) + } + return yield* fromBytes(hdKey.privateKey) + }) + + /** + * Derive a child private key using BIP32 path with Effect error handling. + * + * @since 2.0.0 + * @category bip32 + */ + export const derive = (privateKey: PrivateKey, path: string): Eff.Effect => + Eff.gen(function* () { + const privateKeyBytes = yield* toBytes(privateKey) + const hdKey = BIP32.HDKey.fromMasterSeed(privateKeyBytes) + const childKey = hdKey.derive(path) + if (!childKey.privateKey) { + return yield* Eff.fail(new PrivateKeyError({ message: "No private key in derived HD key" })) + } + return yield* fromBytes(childKey.privateKey) + }) + + /** + * Sign a message using Ed25519 with Effect error handling. + * + * @since 2.0.0 + * @category cryptography + */ + export const sign = (privateKey: PrivateKey, message: Uint8Array): Eff.Effect => + Eff.gen(function* () { + const privateKeyBytes = yield* toBytes(privateKey) + + if (privateKeyBytes.length === 64) { + // CML-compatible extended signing algorithm + const scalar = privateKeyBytes.slice(0, 32) + const iv = privateKeyBytes.slice(32, 64) + + const publicKey = sodium.crypto_scalarmult_ed25519_base_noclamp(scalar) + const nonceHash = sodium.crypto_hash_sha512(new Uint8Array([...iv, ...message])) + const nonce = sodium.crypto_core_ed25519_scalar_reduce(nonceHash) + const r = sodium.crypto_scalarmult_ed25519_base_noclamp(nonce) + const hramHash = sodium.crypto_hash_sha512(new Uint8Array([...r, ...publicKey, ...message])) + const hram = sodium.crypto_core_ed25519_scalar_reduce(hramHash) + const s = sodium.crypto_core_ed25519_scalar_add(sodium.crypto_core_ed25519_scalar_mul(hram, scalar), nonce) + + return new Uint8Array([...r, ...s]) + } + + // Standard 32-byte Ed25519 signing + const publicKey = sodium.crypto_sign_seed_keypair(privateKeyBytes).publicKey + const secretKey = new Uint8Array([...privateKeyBytes, ...publicKey]) + return sodium.crypto_sign_detached(message, secretKey) + }) +} diff --git a/packages/evolution/src/ProposalProcedure.ts b/packages/evolution/src/ProposalProcedure.ts new file mode 100644 index 00000000..189d8728 --- /dev/null +++ b/packages/evolution/src/ProposalProcedure.ts @@ -0,0 +1,285 @@ +import { Data, Effect as Eff, ParseResult, Schema } from "effect" + +import * as Anchor from "./Anchor.js" +import * as Bytes from "./Bytes.js" +import * as CBOR from "./CBOR.js" +import * as Coin from "./Coin.js" +import * as GovernanceAction from "./GovernanceAction.js" +import * as RewardAccount from "./RewardAccount.js" + +/** + * Error class for ProposalProcedure related operations. + * + * @since 2.0.0 + * @category errors + */ +export class ProposalProcedureError extends Data.TaggedError("ProposalProcedureError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Schema for a single proposal procedure based on Conway CDDL specification. + * + * ``` + * proposal_procedure = [ + * deposit : coin, + * reward_account : reward_account, + * governance_action : governance_action, + * anchor : anchor / null + * ] + * + * governance_action = [action_type, action_data] + * ``` + * + * @since 2.0.0 + * @category model + */ +export class ProposalProcedure extends Schema.Class("ProposalProcedure")({ + deposit: Coin.Coin, + rewardAccount: RewardAccount.RewardAccount, + governanceAction: GovernanceAction.GovernanceAction, + anchor: Schema.NullOr(Anchor.Anchor) +}) {} + +/** + * CDDL schema for ProposalProcedure tuple structure. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = Schema.Tuple( + CBOR.Integer, // deposit: coin + CBOR.ByteArray, // reward_account (raw bytes) + Schema.encodedSchema(GovernanceAction.CDDLSchema), // governance_action using proper CDDL schema + Schema.NullOr(Anchor.CDDLSchema) // anchor / null +) + +/** + * CDDL transformation schema for individual ProposalProcedure. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(ProposalProcedure), { + strict: true, + encode: (procedure) => + Eff.gen(function* () { + const depositBigInt = BigInt(procedure.deposit) + const rewardAccountBytes = yield* ParseResult.encode(RewardAccount.FromBytes)(procedure.rewardAccount) + const governanceActionCDDL = yield* ParseResult.encode(GovernanceAction.FromCDDL)(procedure.governanceAction) + const anchorCDDL = procedure.anchor ? yield* ParseResult.encode(Anchor.FromCDDL)(procedure.anchor) : null + return [depositBigInt, rewardAccountBytes, governanceActionCDDL, anchorCDDL] as const + }), + decode: (procedureTuple) => + Eff.gen(function* () { + const [depositBigInt, rewardAccountBytes, governanceActionCDDL, anchorCDDL] = procedureTuple as any + const deposit = Coin.make(depositBigInt) + const rewardAccount = yield* ParseResult.decode(RewardAccount.FromBytes)(rewardAccountBytes) + const governanceAction = yield* ParseResult.decode(GovernanceAction.FromCDDL)(governanceActionCDDL) + const anchor = anchorCDDL ? yield* ParseResult.decode(Anchor.FromCDDL)(anchorCDDL) : null + + return new ProposalProcedure({ + deposit, + rewardAccount, + governanceAction, + anchor + }) + }) +}) + +/** + * CBOR bytes transformation schema for individual ProposalProcedure. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + CBOR.FromBytes(options), // Uint8Array → CBOR + FromCDDL // CBOR → ProposalProcedure + ).annotations({ + identifier: "ProposalProcedure.FromCBORBytes", + title: "ProposalProcedure from CBOR Bytes", + description: "Transforms CBOR bytes to ProposalProcedure" + }) + +/** + * CBOR hex transformation schema for individual ProposalProcedure. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + Bytes.FromHex, // string → Uint8Array + FromCBORBytes(options) // Uint8Array → ProposalProcedure + ).annotations({ + identifier: "ProposalProcedure.FromCBORHex", + title: "ProposalProcedure from CBOR Hex", + description: "Transforms CBOR hex string to ProposalProcedure" + }) + +/** + * Check if two ProposalProcedure instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: ProposalProcedure, b: ProposalProcedure): boolean => + a.deposit === b.deposit && + RewardAccount.equals(a.rewardAccount, b.rewardAccount) && + GovernanceAction.equals(a.governanceAction, b.governanceAction) && + ((a.anchor === null && b.anchor === null) || + (a.anchor !== null && b.anchor !== null && Anchor.equals(a.anchor, b.anchor))) + +/** + * Create a single ProposalProcedure. + * + * @since 2.0.0 + * @category constructors + */ +export const make = (params: { + deposit: Coin.Coin + rewardAccount: RewardAccount.RewardAccount + governanceAction: GovernanceAction.GovernanceAction + anchor?: Anchor.Anchor | null +}): ProposalProcedure => + new ProposalProcedure({ + deposit: params.deposit, + rewardAccount: params.rewardAccount, + governanceAction: params.governanceAction, + anchor: params.anchor ?? null + }) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse individual ProposalProcedure from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): ProposalProcedure => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse individual ProposalProcedure from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): ProposalProcedure => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Encode individual ProposalProcedure to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (procedure: ProposalProcedure, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(procedure, options)) + +/** + * Encode individual ProposalProcedure to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (procedure: ProposalProcedure, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(procedure, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse ProposalProcedure from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new ProposalProcedureError({ + message: "Failed to parse ProposalProcedure from bytes", + cause + }) + ) + ) + + /** + * Parse ProposalProcedure from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new ProposalProcedureError({ + message: "Failed to parse ProposalProcedure from hex", + cause + }) + ) + ) + + /** + * Encode ProposalProcedure to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = ( + procedure: ProposalProcedure, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(procedure).pipe( + Eff.mapError( + (cause) => + new ProposalProcedureError({ + message: "Failed to encode ProposalProcedure to bytes", + cause + }) + ) + ) + + /** + * Encode ProposalProcedure to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = ( + procedure: ProposalProcedure, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORHex(options))(procedure).pipe( + Eff.mapError( + (cause) => + new ProposalProcedureError({ + message: "Failed to encode ProposalProcedure to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/ProposalProcedures.ts b/packages/evolution/src/ProposalProcedures.ts index aeeebe2e..e3ec4bd6 100644 --- a/packages/evolution/src/ProposalProcedures.ts +++ b/packages/evolution/src/ProposalProcedures.ts @@ -1,19 +1,287 @@ -import { Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" + +import * as Anchor from "./Anchor.js" +import * as Bytes from "./Bytes.js" +import * as CBOR from "./CBOR.js" +import * as Coin from "./Coin.js" +import * as GovernanceAction from "./GovernanceAction.js" +import * as ProposalProcedure from "./ProposalProcedure.js" +import * as RewardAccount from "./RewardAccount.js" + +/** + * Error class for ProposalProcedures related operations. + * + * @since 2.0.0 + * @category errors + */ +export class ProposalProceduresError extends Data.TaggedError("ProposalProceduresError")<{ + message?: string + cause?: unknown +}> {} /** - * ProposalProcedures based on Conway CDDL specification + * ProposalProcedures based on Conway CDDL specification. * * ``` * CDDL: proposal_procedures = nonempty_set * ``` * - * This is a non-empty set of proposal procedures. - * * @since 2.0.0 * @category model */ +export class ProposalProcedures extends Schema.Class("ProposalProcedures")({ + procedures: Schema.Array(ProposalProcedure.ProposalProcedure).pipe( + Schema.filter((arr) => arr.length > 0, { + message: () => "ProposalProcedures must contain at least one procedure" + }) + ) +}) {} + +/** + * CDDL schema for ProposalProcedures that produces CBOR-compatible types. + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = Schema.Array(ProposalProcedure.CDDLSchema) + +/** + * CDDL transformation schema for ProposalProcedures. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(ProposalProcedures), { + strict: true, + encode: (toA) => + Eff.all(toA.procedures.map((procedure) => ParseResult.encode(ProposalProcedure.FromCDDL)(procedure))), + decode: (fromA) => + Eff.gen(function* () { + const procedures = yield* Eff.all( + fromA.map((procedureTuple) => ParseResult.decode(ProposalProcedure.FromCDDL)(procedureTuple)) + ) + + return new ProposalProcedures({ procedures }) + }) +}) + +/** + * CBOR bytes transformation schema for ProposalProcedures. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + CBOR.FromBytes(options), // Uint8Array → CBOR + FromCDDL // CBOR → ProposalProcedures + ).annotations({ + identifier: "ProposalProcedures.FromCBORBytes", + title: "ProposalProcedures from CBOR Bytes", + description: "Transforms CBOR bytes to ProposalProcedures" + }) + +/** + * CBOR hex transformation schema for ProposalProcedures. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + Bytes.FromHex, // string → Uint8Array + FromCBORBytes(options) // Uint8Array → ProposalProcedures + ).annotations({ + identifier: "ProposalProcedures.FromCBORHex", + title: "ProposalProcedures from CBOR Hex", + description: "Transforms CBOR hex string to ProposalProcedures" + }) + +/** + * Check if two ProposalProcedures instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: ProposalProcedures, b: ProposalProcedures): boolean => + a.procedures.length === b.procedures.length && + a.procedures.every((procedureA, index) => { + const procedureB = b.procedures[index] + return ProposalProcedure.equals(procedureA, procedureB) + }) + +/** + * Create a ProposalProcedures instance with validation. + * + * @since 2.0.0 + * @category constructors + */ +export const make = (procedures: Array): ProposalProcedures => { + if (procedures.length === 0) { + throw new Error("ProposalProcedures must contain at least one procedure") + } + return new ProposalProcedures({ procedures }) +} + +/** + * Create a single ProposalProcedure. + * + * @since 2.0.0 + * @category constructors + */ +export const makeProcedure = (params: { + deposit: Coin.Coin + rewardAccount: RewardAccount.RewardAccount + governanceAction: GovernanceAction.GovernanceAction + anchor?: Anchor.Anchor | null +}): ProposalProcedure.ProposalProcedure => ProposalProcedure.make(params) + +/** + * FastCheck arbitrary for ProposalProcedures. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.record({ + procedures: FastCheck.array( + FastCheck.record({ + deposit: Coin.arbitrary, + rewardAccount: RewardAccount.arbitrary, + governanceAction: GovernanceAction.arbitrary, + anchor: FastCheck.option(Anchor.arbitrary, { nil: null }) + }).map((params) => ProposalProcedure.make(params)), + { minLength: 1, maxLength: 5 } + ) +}).map((params) => new ProposalProcedures(params)) + +// ============================================================================ +// Root Functions +// ============================================================================ + +/** + * Parse ProposalProcedures from CBOR bytes. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): ProposalProcedures => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Parse ProposalProcedures from CBOR hex string. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): ProposalProcedures => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Encode ProposalProcedures to CBOR bytes. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytes = (proposalProcedures: ProposalProcedures, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(proposalProcedures, options)) + +/** + * Encode ProposalProcedures to CBOR hex string. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (proposalProcedures: ProposalProcedures, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(proposalProcedures, options)) + +// ============================================================================ +// Effect Namespace +// ============================================================================ + +/** + * Effect-based error handling variants for functions that can fail. + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Parse ProposalProcedures from CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = ( + bytes: Uint8Array, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORBytes(options))(bytes).pipe( + Eff.mapError( + (cause) => + new ProposalProceduresError({ + message: "Failed to parse ProposalProcedures from bytes", + cause + }) + ) + ) + + /** + * Parse ProposalProcedures from CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = ( + hex: string, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.decode(FromCBORHex(options))(hex).pipe( + Eff.mapError( + (cause) => + new ProposalProceduresError({ + message: "Failed to parse ProposalProcedures from hex", + cause + }) + ) + ) -// TODO: Implement when ProposalProcedure is available -export const ProposalProcedures = Schema.Unknown + /** + * Encode ProposalProcedures to CBOR bytes with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = ( + proposalProcedures: ProposalProcedures, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORBytes(options))(proposalProcedures).pipe( + Eff.mapError( + (cause) => + new ProposalProceduresError({ + message: "Failed to encode ProposalProcedures to bytes", + cause + }) + ) + ) -export type ProposalProcedures = Schema.Schema.Type + /** + * Encode ProposalProcedures to CBOR hex string with Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = ( + proposalProcedures: ProposalProcedures, + options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS + ): Eff.Effect => + Schema.encode(FromCBORHex(options))(proposalProcedures).pipe( + Eff.mapError( + (cause) => + new ProposalProceduresError({ + message: "Failed to encode ProposalProcedures to hex", + cause + }) + ) + ) +} diff --git a/packages/evolution/src/ProtocolVersion.ts b/packages/evolution/src/ProtocolVersion.ts index cbde9c1d..74a9bfa4 100644 --- a/packages/evolution/src/ProtocolVersion.ts +++ b/packages/evolution/src/ProtocolVersion.ts @@ -1,8 +1,7 @@ -import { Data, Effect, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as Numeric from "./Numeric.js" /** @@ -29,6 +28,18 @@ export class ProtocolVersion extends Schema.TaggedClass()("Prot minor: Numeric.Uint32Schema }) {} +/** + * Smart constructor for creating ProtocolVersion instances + * + * @since 2.0.0 + * @category constructors + */ +export const make = (props: { major: number; minor: number }): ProtocolVersion => + new ProtocolVersion({ + major: Numeric.Uint32Make(props.major), + minor: Numeric.Uint32Make(props.minor) + }) + /** * Check if two ProtocolVersion instances are equal. * @@ -37,6 +48,16 @@ export class ProtocolVersion extends Schema.TaggedClass()("Prot */ export const equals = (a: ProtocolVersion, b: ProtocolVersion): boolean => a.major === b.major && a.minor === b.minor +/** + * FastCheck arbitrary for generating random ProtocolVersion instances + * + * @since 2.0.0 + * @category testing + */ +export const arbitrary = FastCheck.tuple(Numeric.Uint32Generator, Numeric.Uint32Generator).map(([major, minor]) => + make({ major, minor }) +) + /** * CDDL schema for ProtocolVersion. * protocol_version = [major_version : uint32, minor_version : uint32] @@ -49,7 +70,7 @@ export const FromCDDL = Schema.transformOrFail( Schema.typeSchema(ProtocolVersion), { strict: true, - encode: (toA) => Effect.succeed([BigInt(toA.major), BigInt(toA.minor)] as const), + encode: (toA) => Eff.succeed([BigInt(toA.major), BigInt(toA.minor)] as const), decode: ([major, minor]) => ParseResult.decode(ProtocolVersion)({ _tag: "ProtocolVersion", @@ -57,7 +78,10 @@ export const FromCDDL = Schema.transformOrFail( minor: Number(minor) }) } -) +).annotations({ + identifier: "ProtocolVersion.FromCDDL", + description: "Transforms CBOR structure to ProtocolVersion" +}) /** * CBOR bytes transformation schema for ProtocolVersion. @@ -65,11 +89,14 @@ export const FromCDDL = Schema.transformOrFail( * @since 2.0.0 * @category schemas */ -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( CBOR.FromBytes(options), // Uint8Array → CBOR FromCDDL // CBOR → ProtocolVersion - ) + ).annotations({ + identifier: "ProtocolVersion.FromCBORBytes", + description: "Transforms CBOR bytes to ProtocolVersion" + }) /** * CBOR hex transformation schema for ProtocolVersion. @@ -77,17 +104,103 @@ export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) * @since 2.0.0 * @category schemas */ -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array FromCBORBytes(options) // Uint8Array → ProtocolVersion - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - bytes: FromCBORBytes(options), - variableBytes: FromCBORHex(options) - }, - ProtocolVersionError - ) + ).annotations({ + identifier: "ProtocolVersion.FromCBORHex", + description: "Transforms CBOR hex string to ProtocolVersion" + }) + +/** + * Effect namespace for ProtocolVersion operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert CBOR bytes to ProtocolVersion using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(bytes), + (cause) => new ProtocolVersionError({ message: "Failed to decode from CBOR bytes", cause }) + ) + + /** + * Convert CBOR hex string to ProtocolVersion using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.decode(FromCBORHex(options))(hex), + (cause) => new ProtocolVersionError({ message: "Failed to decode from CBOR hex", cause }) + ) + + /** + * Convert ProtocolVersion to CBOR bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORBytes = (version: ProtocolVersion, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(version), + (cause) => new ProtocolVersionError({ message: "Failed to encode to CBOR bytes", cause }) + ) + + /** + * Convert ProtocolVersion to CBOR hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toCBORHex = (version: ProtocolVersion, options?: CBOR.CodecOptions) => + Eff.mapError( + Schema.encode(FromCBORHex(options))(version), + (cause) => new ProtocolVersionError({ message: "Failed to encode to CBOR hex", cause }) + ) +} + +/** + * Convert CBOR bytes to ProtocolVersion (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): ProtocolVersion => + Eff.runSync(Effect.fromCBORBytes(bytes, options)) + +/** + * Convert CBOR hex string to ProtocolVersion (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): ProtocolVersion => + Eff.runSync(Effect.fromCBORHex(hex, options)) + +/** + * Convert ProtocolVersion to CBOR bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORBytes = (version: ProtocolVersion, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(version, options)) + +/** + * Convert ProtocolVersion to CBOR hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORHex = (version: ProtocolVersion, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(version, options)) diff --git a/packages/evolution/src/Redeemer.ts b/packages/evolution/src/Redeemer.ts new file mode 100644 index 00000000..77273dcf --- /dev/null +++ b/packages/evolution/src/Redeemer.ts @@ -0,0 +1,415 @@ +import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" + +import * as Bytes from "./Bytes.js" +import * as CBOR from "./CBOR.js" +import * as PlutusData from "./Data.js" +import * as Numeric from "./Numeric.js" + +/** + * Error class for Redeemer related operations. + * + * @since 2.0.0 + * @category errors + */ +export class RedeemerError extends Data.TaggedError("RedeemerError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Redeemer tag enum for different script execution contexts. + * + * CDDL: redeemer_tag = 0 ; spend | 1 ; mint | 2 ; cert | 3 ; reward + * + * @since 2.0.0 + * @category model + */ +export const RedeemerTag = Schema.Literal("spend", "mint", "cert", "reward").annotations({ + identifier: "Redeemer.Tag", + title: "Redeemer Tag", + description: "Tag indicating the context where the redeemer is used" +}) + +export type RedeemerTag = typeof RedeemerTag.Type + +/** + * Execution units for Plutus script execution. + * + * CDDL: ex_units = [mem: uint64, steps: uint64] + * + * @since 2.0.0 + * @category model + */ +export const ExUnits = Schema.Tuple( + Numeric.Uint64Schema.annotations({ + identifier: "Redeemer.ExUnits.Memory", + title: "Memory Units", + description: "Memory units consumed by script execution" + }), + Numeric.Uint64Schema.annotations({ + identifier: "Redeemer.ExUnits.Steps", + title: "CPU Steps", + description: "CPU steps consumed by script execution" + }) +).annotations({ + identifier: "Redeemer.ExUnits", + title: "Execution Units", + description: "Memory and CPU limits for Plutus script execution" +}) + +export type ExUnits = typeof ExUnits.Type + +/** + * Redeemer for Plutus script execution based on Conway CDDL specification. + * + * CDDL: redeemer = [ tag, index, data, ex_units ] + * Where: + * - tag: redeemer_tag (0=spend, 1=mint, 2=cert, 3=reward) + * - index: uint64 (index into the respective input/output/certificate/reward array) + * - data: plutus_data (the actual redeemer data passed to the script) + * - ex_units: [mem: uint64, steps: uint64] (execution unit limits) + * + * @since 2.0.0 + * @category model + */ +export class Redeemer extends Schema.Class("Redeemer")({ + tag: RedeemerTag, + index: Numeric.Uint64Schema.annotations({ + identifier: "Redeemer.Index", + title: "Redeemer Index", + description: "Index into the respective transaction array (inputs, outputs, certificates, or rewards)" + }), + data: PlutusData.DataSchema.annotations({ + identifier: "Redeemer.Data", + title: "Redeemer Data", + description: "PlutusData passed to the script for validation" + }), + exUnits: ExUnits +}) {} + +/** + * Helper function to convert RedeemerTag string to CBOR integer. + * + * @since 2.0.0 + * @category utilities + */ +export const tagToInteger = (tag: RedeemerTag): bigint => { + switch (tag) { + case "spend": + return 0n + case "mint": + return 1n + case "cert": + return 2n + case "reward": + return 3n + } +} + +/** + * Helper function to convert CBOR integer to RedeemerTag string. + * + * @since 2.0.0 + * @category utilities + */ +export const integerToTag = (value: bigint): RedeemerTag => { + switch (value) { + case 0n: + return "spend" + case 1n: + return "mint" + case 2n: + return "cert" + case 3n: + return "reward" + default: + throw new RedeemerError({ + message: `Invalid redeemer tag: ${value}. Must be 0 (spend), 1 (mint), 2 (cert), or 3 (reward)` + }) + } +} + +/** + * CDDL schema for Redeemer as tuple structure. + * + * CDDL: redeemer = [ tag, index, data, ex_units ] + * + * @since 2.0.0 + * @category schemas + */ +export const CDDLSchema = Schema.Tuple( + CBOR.Integer.annotations({ + identifier: "Redeemer.CDDL.Tag", + title: "Redeemer Tag (CBOR)", + description: "Redeemer tag as CBOR integer (0=spend, 1=mint, 2=cert, 3=reward)" + }), + CBOR.Integer.annotations({ + identifier: "Redeemer.CDDL.Index", + title: "Redeemer Index (CBOR)", + description: "Index into transaction array as CBOR integer" + }), + PlutusData.CDDLSchema.annotations({ + identifier: "Redeemer.CDDL.Data", + title: "Redeemer Data (CBOR)", + description: "PlutusData as CBOR value" + }), + Schema.Tuple(CBOR.Integer, CBOR.Integer).annotations({ + identifier: "Redeemer.CDDL.ExUnits", + title: "Execution Units (CBOR)", + description: "Memory and CPU limits as CBOR integers" + }) +).annotations({ + identifier: "Redeemer.CDDLSchema", + title: "Redeemer CDDL Schema", + description: "CDDL representation of Redeemer as tuple" +}) + +/** + * CDDL transformation schema for Redeemer. + * + * Transforms between CBOR tuple representation and Redeemer class instance. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Redeemer), { + strict: true, + encode: (redeemer) => + Effect.gen(function* () { + const tagInteger = tagToInteger(redeemer.tag) + const dataCBOR = yield* ParseResult.encode(PlutusData.FromCDDL)(redeemer.data) + return [tagInteger, redeemer.index, dataCBOR, redeemer.exUnits] as const + }), + decode: ([tagInteger, index, dataCBOR, exUnits]) => + Effect.gen(function* () { + const tag = yield* Effect.try({ + try: () => integerToTag(tagInteger), + catch: (error) => new ParseResult.Type(RedeemerTag.ast, tagInteger, String(error)) + }) + const data = yield* ParseResult.decode(PlutusData.FromCDDL)(dataCBOR) + return new Redeemer({ tag, index, data, exUnits }) + }) +}) + +/** + * CBOR bytes transformation schema for Redeemer using CDDL. + * Transforms between CBOR bytes and Redeemer using CDDL encoding. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + CBOR.FromBytes(options), // Uint8Array → CBOR + FromCDDL // CBOR → Redeemer + ).annotations({ + identifier: "Redeemer.FromCBORBytes", + title: "Redeemer from CBOR Bytes using CDDL", + description: "Transforms CBOR bytes to Redeemer using CDDL encoding" + }) + +/** + * CBOR hex transformation schema for Redeemer using CDDL. + * Transforms between CBOR hex string and Redeemer using CDDL encoding. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.compose( + Bytes.FromHex, // string → Uint8Array + FromCBORBytes(options) // Uint8Array → Redeemer + ).annotations({ + identifier: "Redeemer.FromCBORHex", + title: "Redeemer from CBOR Hex using CDDL", + description: "Transforms CBOR hex string to Redeemer using CDDL encoding" + }) + +// ============================================================================ +// Constructors +// ============================================================================ + +/** + * Create a spend redeemer for spending UTxO inputs. + * + * @since 2.0.0 + * @category constructors + */ +export const spend = (index: bigint, data: PlutusData.Data, exUnits: ExUnits): Redeemer => + new Redeemer({ tag: "spend", index, data, exUnits }) + +/** + * Create a mint redeemer for minting/burning tokens. + * + * @since 2.0.0 + * @category constructors + */ +export const mint = (index: bigint, data: PlutusData.Data, exUnits: ExUnits): Redeemer => + new Redeemer({ tag: "mint", index, data, exUnits }) + +/** + * Create a cert redeemer for certificate validation. + * + * @since 2.0.0 + * @category constructors + */ +export const cert = (index: bigint, data: PlutusData.Data, exUnits: ExUnits): Redeemer => + new Redeemer({ tag: "cert", index, data, exUnits }) + +/** + * Create a reward redeemer for withdrawal validation. + * + * @since 2.0.0 + * @category constructors + */ +export const reward = (index: bigint, data: PlutusData.Data, exUnits: ExUnits): Redeemer => + new Redeemer({ tag: "reward", index, data, exUnits }) + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Check if a redeemer is for spending inputs. + * + * @since 2.0.0 + * @category predicates + */ +export const isSpend = (redeemer: Redeemer): boolean => redeemer.tag === "spend" + +/** + * Check if a redeemer is for minting/burning. + * + * @since 2.0.0 + * @category predicates + */ +export const isMint = (redeemer: Redeemer): boolean => redeemer.tag === "mint" + +/** + * Check if a redeemer is for certificates. + * + * @since 2.0.0 + * @category predicates + */ +export const isCert = (redeemer: Redeemer): boolean => redeemer.tag === "cert" + +/** + * Check if a redeemer is for withdrawals. + * + * @since 2.0.0 + * @category predicates + */ +export const isReward = (redeemer: Redeemer): boolean => redeemer.tag === "reward" + +// ============================================================================ +// Transformations +// ============================================================================ + +/** + * Encode Redeemer to CBOR bytes. + * + * @since 2.0.0 + * @category transformation + */ +export const toCBORBytes = (redeemer: Redeemer, options?: CBOR.CodecOptions): Uint8Array => { + try { + return Schema.encodeSync(FromCBORBytes(options))(redeemer) + } catch (cause) { + throw new RedeemerError({ + message: "Failed to encode Redeemer to CBOR bytes", + cause + }) + } +} + +/** + * Encode Redeemer to CBOR hex string. + * + * @since 2.0.0 + * @category transformation + */ +export const toCBORHex = (redeemer: Redeemer, options?: CBOR.CodecOptions): string => { + try { + return Schema.encodeSync(FromCBORHex(options))(redeemer) + } catch (cause) { + throw new RedeemerError({ + message: "Failed to encode Redeemer to CBOR hex", + cause + }) + } +} + +/** + * Decode Redeemer from CBOR bytes. + * + * @since 2.0.0 + * @category transformation + */ +export const fromCBORBytes = (bytes: Uint8Array, options?: CBOR.CodecOptions): Redeemer => { + try { + return Schema.decodeSync(FromCBORBytes(options))(bytes) + } catch (cause) { + throw new RedeemerError({ + message: "Failed to decode Redeemer from CBOR bytes", + cause + }) + } +} + +/** + * Decode Redeemer from CBOR hex string. + * + * @since 2.0.0 + * @category transformation + */ +export const fromCBORHex = (hex: string, options?: CBOR.CodecOptions): Redeemer => { + try { + return Schema.decodeSync(FromCBORHex(options))(hex) + } catch (cause) { + throw new RedeemerError({ + message: "Failed to decode Redeemer from CBOR hex", + cause + }) + } +} + +// ============================================================================ +// Generators +// ============================================================================ + +/** + * FastCheck arbitrary for generating random RedeemerTag values. + * + * @since 2.0.0 + * @category generators + */ +export const arbitraryRedeemerTag: FastCheck.Arbitrary = FastCheck.constantFrom( + "spend", + "mint", + "cert", + "reward" +) + +/** + * FastCheck arbitrary for generating random ExUnits values. + * + * @since 2.0.0 + * @category generators + */ +export const arbitraryExUnits: FastCheck.Arbitrary = FastCheck.tuple( + FastCheck.bigInt({ min: 0n, max: 10_000_000n }), // memory + FastCheck.bigInt({ min: 0n, max: 10_000_000n }) // steps +) + +/** + * FastCheck arbitrary for generating random Redeemer instances. + * + * @since 2.0.0 + * @category generators + */ +export const arbitrary: FastCheck.Arbitrary = FastCheck.record({ + index: FastCheck.bigInt({ min: 0n, max: 1000n }), + tag: arbitraryRedeemerTag, + data: PlutusData.arbitrary, + exUnits: arbitraryExUnits +}).map(({ data, exUnits, index, tag }) => new Redeemer({ tag, index, data, exUnits })) diff --git a/packages/evolution/src/Relay.ts b/packages/evolution/src/Relay.ts index 3a768e55..1e5c5028 100644 --- a/packages/evolution/src/Relay.ts +++ b/packages/evolution/src/Relay.ts @@ -1,8 +1,7 @@ -import { Data, FastCheck, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as _Codec from "./Codec.js" import * as MultiHostName from "./MultiHostName.js" import * as SingleHostAddr from "./SingleHostAddr.js" import * as SingleHostName from "./SingleHostName.js" @@ -15,7 +14,7 @@ import * as SingleHostName from "./SingleHostName.js" */ export class RelayError extends Data.TaggedError("RelayError")<{ message?: string - reason?: "InvalidStructure" | "UnsupportedType" + cause?: unknown }> {} /** @@ -31,11 +30,7 @@ export const Relay = Schema.Union( MultiHostName.MultiHostName ) -export const FromCDDL = Schema.Union( - SingleHostAddr.SingleHostAddrCDDLSchema, - SingleHostName.SingleHostNameCDDLSchema, - MultiHostName.FromCDDL -) +export const FromCDDL = Schema.Union(SingleHostAddr.FromCDDL, SingleHostName.FromCDDL, MultiHostName.FromCDDL) /** * Type alias for Relay. @@ -47,14 +42,21 @@ export type Relay = typeof Relay.Type /** * CBOR bytes transformation schema for Relay. - * For union types, we create a union of the child FromBytess - * rather than trying to create a complex three-layer transformation. + * For union types, we create a union of the child CBOR schemas. * * @since 2.0.0 * @category schemas */ -export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - Schema.Union(SingleHostAddr.FromBytes(options), SingleHostName.FromBytes(options), MultiHostName.FromBytes(options)) +export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => + Schema.Union( + SingleHostAddr.FromCBORBytes(options), + SingleHostName.FromBytes(options), // Still uses old naming + MultiHostName.FromCBORBytes(options) + ).annotations({ + identifier: "Relay.FromCBORBytes", + title: "Relay from CBOR Bytes", + description: "Transforms CBOR bytes (Uint8Array) to Relay" + }) /** * CBOR hex transformation schema for Relay. @@ -62,122 +64,211 @@ export const FromBytes = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => * @since 2.0.0 * @category schemas */ -export const FromHex = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose( Bytes.FromHex, // string → Uint8Array - FromBytes(options) // Uint8Array → Relay - ) - -export const Codec = (options: CBOR.CodecOptions = CBOR.DEFAULT_OPTIONS) => - _Codec.createEncoders( - { - cborBytes: FromBytes(options), - cborHex: FromHex(options) - }, - RelayError - ) + FromCBORBytes(options) // Uint8Array → Relay + ).annotations({ + identifier: "Relay.FromCBORHex", + title: "Relay from CBOR Hex", + description: "Transforms CBOR hex string to Relay" + }) /** - * Pattern match on a Relay to handle different relay types. + * Check if two Relay instances are equal. * * @since 2.0.0 - * @category transformation + * @category equality */ -export const match = ( - relay: Relay, - cases: { - SingleHostAddr: (addr: SingleHostAddr.SingleHostAddr) => A - SingleHostName: (name: SingleHostName.SingleHostName) => B - MultiHostName: (multi: MultiHostName.MultiHostName) => C - } -): A | B | C => { - switch (relay._tag) { +export const equals = (self: Relay, that: Relay): boolean => { + if (self._tag !== that._tag) return false + + switch (self._tag) { case "SingleHostAddr": - return cases.SingleHostAddr(relay) + return SingleHostAddr.equals(self, that as SingleHostAddr.SingleHostAddr) case "SingleHostName": - return cases.SingleHostName(relay) + return SingleHostName.equals(self, that as SingleHostName.SingleHostName) case "MultiHostName": - return cases.MultiHostName(relay) + return MultiHostName.equals(self, that as MultiHostName.MultiHostName) default: - throw new Error(`Exhaustive check failed: Unhandled case '${(relay as { _tag: string })._tag}' encountered.`) + return false } } /** - * Check if a Relay is a SingleHostAddr. + * @since 2.0.0 + * @category FastCheck + */ +export const arbitrary = FastCheck.oneof( + SingleHostAddr.arbitrary, + FastCheck.constant({} as SingleHostName.SingleHostName), // Placeholder since it may not have arbitrary + MultiHostName.arbitrary +) + +/** + * Create a Relay from a SingleHostAddr. * * @since 2.0.0 - * @category predicates + * @category constructors */ -export const isSingleHostAddr = (relay: Relay): relay is SingleHostAddr.SingleHostAddr => - relay._tag === "SingleHostAddr" +export const fromSingleHostAddr = (singleHostAddr: SingleHostAddr.SingleHostAddr): Relay => singleHostAddr /** - * Check if a Relay is a SingleHostName. + * Create a Relay from a SingleHostName. * * @since 2.0.0 - * @category predicates + * @category constructors */ -export const isSingleHostName = (relay: Relay): relay is SingleHostName.SingleHostName => - relay._tag === "SingleHostName" +export const fromSingleHostName = (singleHostName: SingleHostName.SingleHostName): Relay => singleHostName /** - * Check if a Relay is a MultiHostName. + * Create a Relay from a MultiHostName. * * @since 2.0.0 - * @category predicates + * @category constructors */ -export const isMultiHostName = (relay: Relay): relay is MultiHostName.MultiHostName => relay._tag === "MultiHostName" +export const fromMultiHostName = (multiHostName: MultiHostName.MultiHostName): Relay => multiHostName /** - * FastCheck generator for Relay instances. + * Effect namespace containing schema decode and encode operations. * * @since 2.0.0 - * @category generators + * @category Effect */ -export const generator = FastCheck.oneof(SingleHostAddr.generator, SingleHostName.generator, MultiHostName.generator) +export namespace Effect { + /** + * Parse a Relay from CBOR bytes using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORBytes = (input: Uint8Array, options?: CBOR.CodecOptions): Eff.Effect => + Eff.mapError( + Schema.decode(FromCBORBytes(options))(input), + (cause) => new RelayError({ message: "Failed to decode Relay from CBOR bytes", cause }) + ) + + /** + * Parse a Relay from CBOR hex using Effect error handling. + * + * @since 2.0.0 + * @category parsing + */ + export const fromCBORHex = (input: string, options?: CBOR.CodecOptions): Eff.Effect => + Eff.mapError( + Schema.decode(FromCBORHex(options))(input), + (cause) => new RelayError({ message: "Failed to decode Relay from CBOR hex", cause }) + ) + + /** + * Convert a Relay to CBOR bytes using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORBytes = (value: Relay, options?: CBOR.CodecOptions): Eff.Effect => + Eff.mapError( + Schema.encode(FromCBORBytes(options))(value), + (cause) => new RelayError({ message: "Failed to encode Relay to CBOR bytes", cause }) + ) + + /** + * Convert a Relay to CBOR hex using Effect error handling. + * + * @since 2.0.0 + * @category encoding + */ + export const toCBORHex = (value: Relay, options?: CBOR.CodecOptions): Eff.Effect => + Eff.mapError( + Schema.encode(FromCBORHex(options))(value), + (cause) => new RelayError({ message: "Failed to encode Relay to CBOR hex", cause }) + ) +} /** - * Check if two Relay instances are equal. + * Convert Relay to CBOR bytes (unsafe). * * @since 2.0.0 - * @category equality + * @category encoding */ -export const equals = (self: Relay, that: Relay): boolean => { - if (self._tag !== that._tag) return false +export const toCBORBytes = (value: Relay, options?: CBOR.CodecOptions): Uint8Array => + Eff.runSync(Effect.toCBORBytes(value, options)) - switch (self._tag) { +/** + * Convert Relay to CBOR hex (unsafe). + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHex = (value: Relay, options?: CBOR.CodecOptions): string => + Eff.runSync(Effect.toCBORHex(value, options)) + +/** + * Parse Relay from CBOR bytes (unsafe). + * + * @since 2.0.0 + * @category decoding + */ +export const fromCBORBytes = (value: Uint8Array, options?: CBOR.CodecOptions): Relay => + Eff.runSync(Effect.fromCBORBytes(value, options)) + +/** + * Parse Relay from CBOR hex (unsafe). + * + * @since 2.0.0 + * @category decoding + */ +export const fromCBORHex = (value: string, options?: CBOR.CodecOptions): Relay => + Eff.runSync(Effect.fromCBORHex(value, options)) + +/** + * Pattern match on a Relay to handle different relay types. + * + * @since 2.0.0 + * @category transformation + */ +export const match = ( + relay: Relay, + cases: { + SingleHostAddr: (addr: SingleHostAddr.SingleHostAddr) => A + SingleHostName: (name: SingleHostName.SingleHostName) => B + MultiHostName: (multi: MultiHostName.MultiHostName) => C + } +): A | B | C => { + switch (relay._tag) { case "SingleHostAddr": - return SingleHostAddr.equals(self, that as SingleHostAddr.SingleHostAddr) + return cases.SingleHostAddr(relay) case "SingleHostName": - return SingleHostName.equals(self, that as SingleHostName.SingleHostName) + return cases.SingleHostName(relay) case "MultiHostName": - return MultiHostName.equals(self, that as MultiHostName.MultiHostName) + return cases.MultiHostName(relay) default: - return false + throw new Error(`Exhaustive check failed: Unhandled case '${(relay as { _tag: string })._tag}' encountered.`) } } /** - * Create a Relay from a SingleHostAddr. + * Check if a Relay is a SingleHostAddr. * * @since 2.0.0 - * @category constructors + * @category predicates */ -export const fromSingleHostAddr = (singleHostAddr: SingleHostAddr.SingleHostAddr): Relay => singleHostAddr +export const isSingleHostAddr = (relay: Relay): relay is SingleHostAddr.SingleHostAddr => + relay._tag === "SingleHostAddr" /** - * Create a Relay from a SingleHostName. + * Check if a Relay is a SingleHostName. * * @since 2.0.0 - * @category constructors + * @category predicates */ -export const fromSingleHostName = (singleHostName: SingleHostName.SingleHostName): Relay => singleHostName +export const isSingleHostName = (relay: Relay): relay is SingleHostName.SingleHostName => + relay._tag === "SingleHostName" /** - * Create a Relay from a MultiHostName. + * Check if a Relay is a MultiHostName. * * @since 2.0.0 - * @category constructors + * @category predicates */ -export const fromMultiHostName = (multiHostName: MultiHostName.MultiHostName): Relay => multiHostName +export const isMultiHostName = (relay: Relay): relay is MultiHostName.MultiHostName => relay._tag === "MultiHostName" diff --git a/packages/evolution/src/RewardAccount.ts b/packages/evolution/src/RewardAccount.ts index 9865f7cb..067a2f39 100644 --- a/packages/evolution/src/RewardAccount.ts +++ b/packages/evolution/src/RewardAccount.ts @@ -1,8 +1,7 @@ -import { Data, Effect, FastCheck, ParseResult, Schema } from "effect" +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" import * as Bytes from "./Bytes.js" import * as Bytes29 from "./Bytes29.js" -import * as _Codec from "./Codec.js" import * as Credential from "./Credential.js" import * as KeyHash from "./KeyHash.js" import * as NetworkId from "./NetworkId.js" @@ -22,20 +21,12 @@ export class RewardAccountError extends Data.TaggedError("RewardAccountError")<{ export class RewardAccount extends Schema.TaggedClass("RewardAccount")("RewardAccount", { networkId: NetworkId.NetworkId, stakeCredential: Credential.Credential -}) { - [Symbol.for("nodejs.util.inspect.custom")]() { - return { - _tag: "RewardAccount", - networkId: this.networkId, - stakeCredential: this.stakeCredential - } - } -} +}) {} export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, RewardAccount, { strict: true, encode: (_, __, ___, toA) => - Effect.gen(function* () { + Eff.gen(function* () { const stakingBit = toA.stakeCredential._tag === "KeyHash" ? 0 : 1 const header = (0b111 << 5) | (stakingBit << 4) | (toA.networkId & 0b00001111) const result = new Uint8Array(29) @@ -45,7 +36,7 @@ export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, RewardAccou return yield* ParseResult.succeed(result) }), decode: (_, __, ___, fromA) => - Effect.gen(function* () { + Eff.gen(function* () { const header = fromA[0] // Extract network ID from the lower 4 bits const networkId = header & 0b00001111 @@ -60,7 +51,7 @@ export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, RewardAccou } : { _tag: "ScriptHash", - hash: yield* ParseResult.decode(ScriptHash.BytesSchema)(fromA.slice(1, 29)) + hash: yield* ParseResult.decode(ScriptHash.FromBytes)(fromA.slice(1, 29)) } return yield* ParseResult.decode(RewardAccount)({ _tag: "RewardAccount", @@ -68,12 +59,29 @@ export const FromBytes = Schema.transformOrFail(Bytes29.BytesSchema, RewardAccou stakeCredential }) }) +}).annotations({ + identifier: "RewardAccount.FromBytes", + description: "Transforms raw bytes to RewardAccount" }) export const FromHex = Schema.compose( Bytes.FromHex, // string → Uint8Array FromBytes // Uint8Array → RewardAccount -) +).annotations({ + identifier: "RewardAccount.FromHex", + description: "Transforms raw hex string to RewardAccount" +}) + +/** + * Smart constructor for creating RewardAccount instances + * + * @since 2.0.0 + * @category constructors + */ +export const make = (props: { + networkId: NetworkId.NetworkId + stakeCredential: Credential.Credential +}): RewardAccount => new RewardAccount(props) /** * Check if two RewardAccount instances are equal. @@ -90,23 +98,103 @@ export const equals = (a: RewardAccount, b: RewardAccount): boolean => { } /** - * Generate a random RewardAccount. + * FastCheck arbitrary for generating random RewardAccount instances * * @since 2.0.0 - * @category generators + * @category testing */ -export const generator = FastCheck.tuple(NetworkId.generator, Credential.generator).map( +export const arbitrary = FastCheck.tuple(NetworkId.arbitrary, Credential.arbitrary).map( ([networkId, stakeCredential]) => - new RewardAccount({ + make({ networkId, stakeCredential }) ) -export const Codec = _Codec.createEncoders( - { - bytes: FromBytes, - hex: FromHex - }, - RewardAccountError -) +/** + * Effect namespace for RewardAccount operations that can fail + * + * @since 2.0.0 + * @category effect + */ +export namespace Effect { + /** + * Convert bytes to RewardAccount using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromBytes = (bytes: Uint8Array) => + Eff.mapError( + Schema.decode(FromBytes)(bytes), + (cause) => new RewardAccountError({ message: "Failed to decode from bytes", cause }) + ) + + /** + * Convert hex string to RewardAccount using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const fromHex = (hex: string) => + Eff.mapError( + Schema.decode(FromHex)(hex), + (cause) => new RewardAccountError({ message: "Failed to decode from hex", cause }) + ) + + /** + * Convert RewardAccount to bytes using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toBytes = (account: RewardAccount) => + Eff.mapError( + Schema.encode(FromBytes)(account), + (cause) => new RewardAccountError({ message: "Failed to encode to bytes", cause }) + ) + + /** + * Convert RewardAccount to hex string using Effect + * + * @since 2.0.0 + * @category conversion + */ + export const toHex = (account: RewardAccount) => + Eff.mapError( + Schema.encode(FromHex)(account), + (cause) => new RewardAccountError({ message: "Failed to encode to hex", cause }) + ) +} + +/** + * Convert bytes to RewardAccount (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromBytes = (bytes: Uint8Array): RewardAccount => Eff.runSync(Effect.fromBytes(bytes)) + +/** + * Convert hex string to RewardAccount (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const fromHex = (hex: string): RewardAccount => Eff.runSync(Effect.fromHex(hex)) + +/** + * Convert RewardAccount to bytes (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toBytes = (account: RewardAccount): Uint8Array => Eff.runSync(Effect.toBytes(account)) + +/** + * Convert RewardAccount to hex string (unsafe) + * + * @since 2.0.0 + * @category conversion + */ +export const toHex = (account: RewardAccount): string => Eff.runSync(Effect.toHex(account)) diff --git a/packages/evolution/src/RewardAddress.ts b/packages/evolution/src/RewardAddress.ts index 6e734c1f..0db9d9e3 100644 --- a/packages/evolution/src/RewardAddress.ts +++ b/packages/evolution/src/RewardAddress.ts @@ -1,4 +1,15 @@ -import { Schema } from "effect" +import { Data, FastCheck, Schema } from "effect" + +/** + * Error class for RewardAddress related operations. + * + * @since 2.0.0 + * @category errors + */ +export class RewardAddressError extends Data.TaggedError("RewardAddressError")<{ + message?: string + cause?: unknown +}> {} /** * Reward address format schema (human-readable addresses) @@ -7,9 +18,12 @@ import { Schema } from "effect" * @since 2.0.0 * @category schemas */ -export const RewardAddress = Schema.String.pipe(Schema.pattern(/^(stake|stake_test)[1][a-z0-9]+$/i)).pipe( +export const RewardAddress = Schema.String.pipe( + Schema.pattern(/^(stake|stake_test)[1][a-z0-9]+$/i), Schema.brand("RewardAddress") -) +).annotations({ + identifier: "RewardAddress" +}) /** * Type representing a reward/stake address string in bech32 format @@ -17,7 +31,23 @@ export const RewardAddress = Schema.String.pipe(Schema.pattern(/^(stake|stake_te * @since 2.0.0 * @category model */ -export type RewardAddress = Schema.Schema.Type +export type RewardAddress = typeof RewardAddress.Type + +/** + * Smart constructor for RewardAddress that validates and applies branding. + * + * @since 2.0.0 + * @category constructors + */ +export const make = RewardAddress.make + +/** + * Check if two RewardAddress instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: RewardAddress, b: RewardAddress): boolean => a === b /** * Check if the given value is a valid RewardAddress @@ -26,3 +56,13 @@ export type RewardAddress = Schema.Schema.Type * @category predicates */ export const isRewardAddress = Schema.is(RewardAddress) + +/** + * FastCheck arbitrary for generating random RewardAddress instances. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary = FastCheck.string({ minLength: 50, maxLength: 100 }) + .filter((str) => /^(stake|stake_test)[1][a-z0-9]+$/i.test(str)) + .map((str) => make(str)) diff --git a/packages/evolution/src/Script.ts b/packages/evolution/src/Script.ts new file mode 100644 index 00000000..44eab657 --- /dev/null +++ b/packages/evolution/src/Script.ts @@ -0,0 +1,171 @@ +import { Data, Effect as Eff, FastCheck, ParseResult, Schema } from "effect" + +import * as CBOR from "./CBOR.js" +import * as NativeScripts from "./NativeScripts.js" +import * as PlutusV1 from "./PlutusV1.js" +import * as PlutusV2 from "./PlutusV2.js" +import * as PlutusV3 from "./PlutusV3.js" + +/** + * Error class for Script related operations. + * + * @since 2.0.0 + * @category errors + */ +export class ScriptError extends Data.TaggedError("ScriptError")<{ + message?: string + cause?: unknown +}> {} + +/** + * Script union type following Conway CDDL specification. + * + * CDDL: + * script = + * [ 0, native_script ] + * / [ 1, plutus_v1_script ] + * / [ 2, plutus_v2_script ] + * / [ 3, plutus_v3_script ] + * + * @since 2.0.0 + * @category model + */ +export const Script = Schema.Union( + NativeScripts.Native, + PlutusV1.PlutusV1, + PlutusV2.PlutusV2, + PlutusV3.PlutusV3 +).annotations({ + identifier: "Script", + description: "Script union (native | plutus_v1 | plutus_v2 | plutus_v3)" +}) + +export type Script = typeof Script.Type + +/** + * CDDL schema for Script as tagged tuples. + * + * @since 2.0.0 + * @category schemas + */ +export const ScriptCDDL = Schema.Union( + Schema.Tuple(Schema.Literal(0n), NativeScripts.CDDLSchema), + Schema.Tuple(Schema.Literal(1n), CBOR.ByteArray), // plutus_v1_script + Schema.Tuple(Schema.Literal(2n), CBOR.ByteArray), // plutus_v2_script + Schema.Tuple(Schema.Literal(3n), CBOR.ByteArray) // plutus_v3_script +).annotations({ + identifier: "Script.CDDL", + description: "CDDL representation of Script as tagged tuples" +}) + +export type ScriptCDDL = typeof ScriptCDDL.Type + +/** + * Transformation between CDDL representation and Script union. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = Schema.transformOrFail(ScriptCDDL, Script, { + strict: true, + encode: (value, _, ast) => { + // Handle native scripts (no _tag property, has type property) + if ("type" in value) { + return NativeScripts.internalEncodeCDDL(value as NativeScripts.Native).pipe( + Eff.map((nativeCDDL) => [0n, nativeCDDL] as const), + Eff.mapError((cause) => new ParseResult.Type(ast, value, `Failed to encode native script: ${cause}`)) + ) + } + + // Handle Plutus scripts (with _tag property) + if ("_tag" in value) { + const plutusScript = value as PlutusV1.PlutusV1 | PlutusV2.PlutusV2 | PlutusV3.PlutusV3 + switch (plutusScript._tag) { + case "PlutusV1": + return Eff.succeed([1n, plutusScript.script] as const) + case "PlutusV2": + return Eff.succeed([2n, plutusScript.script] as const) + case "PlutusV3": + return Eff.succeed([3n, plutusScript.script] as const) + default: + return Eff.fail(new ParseResult.Type(ast, value, `Unknown Plutus script type: ${(plutusScript as any)._tag}`)) + } + } + + return Eff.fail(new ParseResult.Type(ast, value, "Invalid script structure")) + }, + decode: (tuple, _, ast) => { + const [tag, data] = tuple + switch (tag) { + case 0n: + // Native script + return NativeScripts.internalDecodeCDDL(data as NativeScripts.NativeCDDL).pipe( + Eff.mapError((cause) => new ParseResult.Type(ast, tuple, `Failed to decode native script: ${cause}`)) + ) + case 1n: + // PlutusV1 + return Eff.succeed(new PlutusV1.PlutusV1({ script: data as Uint8Array })) + case 2n: + // PlutusV2 + return Eff.succeed(new PlutusV2.PlutusV2({ script: data as Uint8Array })) + case 3n: + // PlutusV3 + return Eff.succeed(new PlutusV3.PlutusV3({ script: data as Uint8Array })) + default: + return Eff.fail(new ParseResult.Type(ast, tuple, `Unknown script tag: ${tag}`)) + } + } +}).annotations({ + identifier: "Script.FromCDDL", + title: "Script from CDDL", + description: "Transforms between CDDL tagged tuple and Script union" +}) + +/** + * Check if two Script instances are equal. + * + * @since 2.0.0 + * @category equality + */ +export const equals = (a: Script, b: Script): boolean => { + // Handle native scripts (no _tag property, has type property) + if ("type" in a && "type" in b) { + // Simple JSON comparison for native scripts + return JSON.stringify(a) === JSON.stringify(b) + } + + // Handle Plutus scripts (with _tag property) + if ("_tag" in a && "_tag" in b) { + if (a._tag !== b._tag) return false + + switch (a._tag) { + case "PlutusV1": + return PlutusV1.equals(a, b as PlutusV1.PlutusV1) + case "PlutusV2": + return PlutusV2.equals(a, b as PlutusV2.PlutusV2) + case "PlutusV3": + return PlutusV3.equals(a, b as PlutusV3.PlutusV3) + default: + return a === b + } + } + + return false +} + +/** + * FastCheck arbitrary for Script. + * + * @since 2.0.0 + * @category arbitrary + */ +export const arbitrary: FastCheck.Arbitrary