UNPKG

@lifi/compose-spec

Version:

Public wire-format types and schemas for Compose flows

152 lines (138 loc) 7.15 kB
import { Schema } from 'effect'; import { isAsErc20Chain } from './chainCapabilities.js'; import { asErc20BalanceGteCheck, AssertionOwnerSchema, CallCheckSpecSchema, Erc20BalanceDeltaGteSpecSchema, HexStringSchema, NativeBalanceDeltaGteSpecSchema, } from './continuation.js'; import type { AssertionSpec } from './continuation.js'; import { ChainIdSchema } from './flowSchema.js'; import { BYTES32_LOWER_HEX } from './hexPatterns.js'; // The neutral per-leg verification descriptor (verified continuations, plan // GD-3, seam C4). It is the wire-visible statement of what a committed // validation program enforces, so a user funding phase 1 can give informed // consent (PRD goal G8). It is backend-neutral (G7): no validator address, no // constraint, no settlement-backend vocabulary — the only escrow-ish term is the // symbolic owner `"escrow"`, explicitly allowlisted by the boundary check. // // The design trick that makes disclosure trustworthy-by-construction: // `assertedPredicate[]` is NOT a new DSL. It is the canonical JSON serialization // of the same `AssertionSpec[]` the validation program is derived from, so // disclosure cannot drift from enforcement — both are projections of one array. // `ChainIdSchema` (from `flowSchema.ts`) and `HexStringSchema` (from // `continuation.ts`) are reused directly, so the widened members below differ // from their readiness counterparts in exactly one field (`owner`) with no // risk of a drifting re-declaration. // The `validationProgramHash` (GD-2, seam C1): `keccak256` over the canonical // program body. Strict 32-byte lowercase hex (`BYTES32_LOWER_HEX`) — see the // rationale in `hexPatterns.ts`. export const Bytes32HexSchema = Schema.String.pipe( Schema.pattern(BYTES32_LOWER_HEX), ); // The disclosure-path widening of the two balance-`Gte` readiness members: same // fields as their `continuation.ts` counterparts, but `owner` accepts a symbolic // `"escrow"`/`"delivery"` too. The derivation genuinely produces such values — // a captured invariant can carry a symbolic owner, which `resolveOwner` maps to // a validator-injected slot at settlement (see the `uc1` invariant). The // readiness `CheckSchema` stays hex-only on purpose (a polled `Check` has no // injected slot), so this widening lives in the descriptor union only. export const AssertedErc20BalanceGteSpecSchema = Schema.Struct({ kind: Schema.Literal('erc20BalanceGte'), chainId: ChainIdSchema, token: HexStringSchema, owner: AssertionOwnerSchema, minAmount: Schema.String, }); export const AssertedNativeBalanceGteSpecSchema = Schema.Struct({ kind: Schema.Literal('nativeBalanceGte'), chainId: ChainIdSchema, owner: AssertionOwnerSchema, minAmount: Schema.String, }); // The disclosed predicate: the five `AssertionSpec` kinds, with symbolic owners // admitted on the two balance-`Gte` members. The `call` member and the two // derivation-only delta members are reused verbatim from `continuation.ts` (the // delta members already carry `AssertionOwnerSchema`). const RawAssertedPredicateSchema = Schema.Union( AssertedErc20BalanceGteSpecSchema, AssertedNativeBalanceGteSpecSchema, CallCheckSpecSchema, Erc20BalanceDeltaGteSpecSchema, NativeBalanceDeltaGteSpecSchema, ); // Same `nativeBalanceGte` → `erc20BalanceGte` rewrite as `CheckSchema` (see // there), applied to the disclosed/committed predicate so the settlement-side // re-assertion derives the identical program on `as-erc20` chains and the // authoring↔settlement hashes stay equal. A committed action authored through // the fixed `CheckSchema` already carries the erc20 form, so this pass is // idempotent there; it additionally normalizes a hand-forged committed action // that embeds a raw native check. The delta members (`nativeBalanceDeltaGte`) // are intentionally untouched — they derive from already-normalized outcomes. export const AssertedPredicateSchema = Schema.transform( RawAssertedPredicateSchema, Schema.typeSchema(RawAssertedPredicateSchema), { strict: true, decode: (pred) => pred.kind === 'nativeBalanceGte' && isAsErc20Chain(pred.chainId) ? asErc20BalanceGteCheck(pred.chainId, pred.owner, pred.minAmount) : pred, encode: (pred) => pred, }, ); export const VerificationDescriptorSchema = Schema.Struct({ validationProgramHash: Bytes32HexSchema, // The C5 echo's second hash (verified continuations, plan GD-8): `keccak256` // over the canonical params vector (`paramsHash`, GD-2 seam C1). Required and // jointly necessary with `validationProgramHash` — the program hash is // value-blind (it never binds the delivery address or amounts), so a descriptor // that enforces only it is a fund-redirect hole. Empty params still yield a // present `0x00…00`, so a real descriptor never omits it. Strict 32-byte // lowercase hex, same rule as `validationProgramHash`. paramsHash: Bytes32HexSchema, assertedPredicate: Schema.Array(AssertedPredicateSchema), }); // Deterministic, key-order-pinned JSON serialization of an `AssertionSpec[]`. // Canonicalization rules: // - Object keys are emitted in recursive lexicographic order (sorted). // - Compact separators — no whitespace anywhere. // - Array element order is PRESERVED, never sorted: it is semantic (derivation // order — deltas, then invariants) and is a faithful projection of the // program's assertion order. // // All `AssertionSpec` leaf values are JSON-safe (decimal strings, `0x` hex // strings, positive-int `chainId`), so lexicographic key sorting is total and // stable with no number-canonicalization edge cases, and is self-maintaining // when members gain fields. Embedding this string per fixture vector pins the // serialization cross-repo (GD-5's consumer asserts it reproduces the string). // // Boundary-forced local mirror of `@lifi/utils`'s `canonicalJson`: compose-spec // is the wire-format leaf and may not import `@lifi/utils` (enforced by the // compose boundary check), so do not "fix" this duplication by importing across // the boundary. const canonicalJson = (value: unknown): string => { if (Array.isArray(value)) { return `[${value.map(canonicalJson).join(',')}]`; } if (value !== null && typeof value === 'object') { const record = value as Record<string, unknown>; const entries = Object.keys(record) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`); return `{${entries.join(',')}}`; } return JSON.stringify(value); }; export const canonicalAssertedPredicate = ( specs: readonly AssertionSpec[], ): string => canonicalJson(specs); export type VerificationDescriptor = typeof VerificationDescriptorSchema.Type; // `AssertedPredicate` is structurally identical to `AssertionSpec` at the TS // type level — `AssertionOwner` is a subtype of `string`, so widening `owner` // changes only schema *decode* behaviour, not the static type. It is therefore // exported as a plain alias, keeping `canonicalAssertedPredicate` and the // derivation's `specs` array interchangeable without casts. export type AssertedPredicate = AssertionSpec;