UNPKG

@lifi/compose-spec

Version:

Public wire-format types and schemas for Compose flows

279 lines (257 loc) 12.3 kB
import { Schema } from 'effect'; import { canonicalNativeAddress, isAsErc20Chain, normalizeNativeTokenAddress, } from './chainCapabilities.js'; import { ChainIdSchema, FlowSchema } from './flowSchema.js'; // The op id of the self-describing commitment carrier node embedded in a // persisted committed continuation action (verified continuations, plan GD-8, // seam C5). It is wire vocabulary — the node's `op` value inside a re-submitted // `Flow` — so it lives here as the single source of truth. The op definition // (compose-builtins) and the settlement-echo trigger (compose-core) both import // this constant; a drift between them would make the echo fail open (a genuine // settlement fill answered as an ordinary flow with no descriptor). export const CONTINUATION_COMMITMENT_OP_ID = 'continuation.commitment'; // A single committed outcome of a deferred settlement: at least `minAmount` of // `token` must be delivered to `destination` on `chainId`. Amounts are decimal // strings on the wire because JSON has no bigint; `chainId` reuses the shared // `ChainIdSchema` from `flowSchema.ts`. const RawContinuationOutcomeSchema = Schema.Struct({ token: Schema.String, minAmount: Schema.String, destination: Schema.String, chainId: ChainIdSchema, }); // Decode transform mirroring `ResourceSchema`: `token` is a bare string that // never flows through `ResourceSchema`, so on `as-erc20` chains the native // sentinel is rewritten to the chain's ERC-20 here — otherwise downstream would // treat the delivery asset as native. Encode is the identity (the ERC-20 form // is valid wire input). export const ContinuationOutcomeSchema = Schema.transform( RawContinuationOutcomeSchema, Schema.typeSchema(RawContinuationOutcomeSchema), { strict: true, decode: (raw) => ({ ...raw, token: normalizeNativeTokenAddress(raw.token, raw.chainId), }), encode: (outcome) => outcome, }, ); // Backend-neutral config for the `continuation.settle` op. Note: the funding // input is NOT a config field — the deferred step's funding resource/amount is // wired via the node's `bind.input` (see M3b). Only the committed terms below // (`outcomes`/`continuationFlow`/`validity`/`perpetual`/`provider`) are the // node's `config`. // // `continuationFlow` is an inlined Flow document that the settlement backend // stores and runs at settlement time against the deferred proceeds (used by the // cross-chain path). It is independent of the Flow's top-level `continuation` // pointer. `FlowSchema` lives in the same package, so importing it here keeps // `compose-spec` dependency-free. export const ContinuationIntentSchema = Schema.Struct({ // `NonEmptyArray` enforces ≥1 outcome at the parse boundary, so downstream needs no guard. outcomes: Schema.NonEmptyArray(ContinuationOutcomeSchema), continuationFlow: Schema.optional(FlowSchema), validity: Schema.Struct({ expiresAtMs: Schema.Number }), perpetual: Schema.optional(Schema.Boolean), provider: Schema.optional(Schema.String), }); // The phase-1 action a client must take after compiling a deferred flow. // // - `transaction` — a funding transaction (fund an escrow / call a protocol // open). Usually the client funds via the main response-level // `transactionRequest`; a venue whose phase-1 tx differs may instead emit an // explicit `transactionRequest` override here. // - `signables` — EIP-712 order(s) to sign (for signed-order venues; not // produced in v1, defined for the seam). // - `none` — nothing to sign now. export const Phase1ArtifactSchema = Schema.Union( Schema.Struct({ kind: Schema.Literal('transaction'), transactionRequest: Schema.optional( Schema.Struct({ to: Schema.String, data: Schema.String, value: Schema.String, }), ), }), Schema.Struct({ kind: Schema.Literal('signables'), typedData: Schema.Array(Schema.Unknown), }), Schema.Struct({ kind: Schema.Literal('none') }), ); // A readiness predicate evaluated against on-chain state. It is both a // client-pollable check ("is the second leg ready yet?") and an on-chain // assertion compiled into the second leg, so a premature re-submission reverts // safely. Amounts are decimal strings on the wire because JSON has no bigint. // // - `erc20BalanceGte` — `owner`'s balance of `token` on `chainId` is at least // `minAmount`. // - `nativeBalanceGte` — `owner`'s native balance on `chainId` is at least // `minAmount`. // - `call` — a generic `eth_call` to `to` with `calldata`, comparing the // returned value against `value` using `comparator`. // // Address/bytes fields (`token`/`owner`/`to`/`calldata`) require a `0x` prefix. // Validation is intentionally lenient (prefix only, not full 40-hex) to match // the rest of the compose wire format — `ContinuationOutcomeSchema.token`/ // `destination` are bare strings too — and to keep placeholder fixtures usable; // the exact address is validated by `toAddress` when the check is lowered to an // on-chain assertion. The prefix gate still rejects empty/garbage values early. export const HexStringSchema = Schema.String.pipe(Schema.startsWith('0x')); // A balance owner in a derivation-only assertion spec. Concrete addresses stay // hex; the two symbolic owners `"escrow"` / `"delivery"` are resolved to // validator-injected register handles at settlement time (see the // verified-continuations derivation). This widening applies ONLY to the // derivation-only delta members below — never to the wire `Check` members. A // readiness `Check` is authored/re-submitted through `/compose` and its owner // has no injected slot, so a symbolic owner there cannot be resolved (it would // throw at lowering); keeping `Check` owners hex-only rejects such a value // cleanly at validation instead. export const AssertionOwnerSchema = Schema.Union( HexStringSchema, Schema.Literal('escrow', 'delivery'), ); export type AssertionOwner = typeof AssertionOwnerSchema.Type; // The three `CheckSchema` members are exported by name so the derivation // (`AssertionSpec`) can reuse them without re-declaring their shape. Their // `owner` is hex-only: a captured invariant always resolves to a concrete // address, and a readiness `Check` never carries a symbolic owner. export const Erc20BalanceGteSpecSchema = Schema.Struct({ kind: Schema.Literal('erc20BalanceGte'), chainId: ChainIdSchema, token: HexStringSchema, owner: HexStringSchema, minAmount: Schema.String, }); export const NativeBalanceGteSpecSchema = Schema.Struct({ kind: Schema.Literal('nativeBalanceGte'), chainId: ChainIdSchema, owner: HexStringSchema, minAmount: Schema.String, }); export const CallCheckSpecSchema = Schema.Struct({ kind: Schema.Literal('call'), chainId: ChainIdSchema, to: HexStringSchema, calldata: HexStringSchema, comparator: Schema.Literal('gte', 'eq', 'lte'), value: Schema.String, }); // The equivalent `erc20BalanceGte` of a `nativeBalanceGte` spec on an // `as-erc20` chain: the chain's stand-in ERC-20 (PathUSD on Tempo) as `token`, // every other field carried through. Shared by `CheckSchema` and the descriptor // union `AssertedPredicateSchema` so both wire shapes rewrite identically — // authoring↔settlement hash parity requires the readiness capture and the // committed re-assertion to derive the *same* program. `owner` is `string` for // both callers (`AssertionOwnerSchema.Type` collapses to `string`). export const asErc20BalanceGteCheck = ( chainId: number, owner: string, minAmount: string, ) => ({ kind: 'erc20BalanceGte' as const, chainId, token: canonicalNativeAddress(chainId), owner, minAmount, }); // The pre-transform union of the three named members. const RawCheckSchema = Schema.Union( Erc20BalanceGteSpecSchema, NativeBalanceGteSpecSchema, CallCheckSpecSchema, ); // Decode transform mirroring `ContinuationOutcomeSchema`: a `nativeBalanceGte` // check carries no `token`, so on an `as-erc20` chain it is rewritten into an // `erc20BalanceGte` of the chain's stand-in ERC-20. Without this it lowers to // `builder.native.getBalance` (in the assertion→builder lowering, a later PR of // this stack), which reads ~0 on a // chain whose gas coin is really an ERC-20 — the readiness check and its // embedded on-chain assertion then fail permanently, making a legitimate // continuation un-settleable. Kind-changing (unlike the outcome fix's token // swap) because the native member has no `token` field. Only `nativeBalanceGte` // is touched; every other member decodes unchanged. Idempotent: an already // `erc20BalanceGte` spec and every evm-native chain pass through. Encode is the // identity (the erc20 form is valid wire input). export const CheckSchema = Schema.transform( RawCheckSchema, Schema.typeSchema(RawCheckSchema), { strict: true, decode: (check) => check.kind === 'nativeBalanceGte' && isAsErc20Chain(check.chainId) ? asErc20BalanceGteCheck(check.chainId, check.owner, check.minAmount) : check, encode: (check) => check, }, ); // The derivation vocabulary (verified continuations, plan GD-1). An // `AssertionSpec` is the structured, backend-neutral form of one invariant a // derived validation program must enforce against the executor's re-quoted // fill. It extends the three `Check` members (reused verbatim) with two // derivation-only members. Amounts stay decimal strings on the wire, mirroring // `Check`. // // - `erc20BalanceDeltaGte` / `nativeBalanceDeltaGte` — `owner`'s post-fill // balance of `token` (native) on `chainId` must exceed its pre-fill balance // by at least `minDelta`. Derived from each committed outcome's floor; // asserted as `post ≥ pre + minDelta` (the pre-balance is validator-injected // at settlement). export const Erc20BalanceDeltaGteSpecSchema = Schema.Struct({ kind: Schema.Literal('erc20BalanceDeltaGte'), chainId: ChainIdSchema, token: HexStringSchema, owner: AssertionOwnerSchema, minDelta: Schema.String, }); export const NativeBalanceDeltaGteSpecSchema = Schema.Struct({ kind: Schema.Literal('nativeBalanceDeltaGte'), chainId: ChainIdSchema, owner: AssertionOwnerSchema, minDelta: Schema.String, }); export const AssertionSpecSchema = Schema.Union( Erc20BalanceGteSpecSchema, NativeBalanceGteSpecSchema, CallCheckSpecSchema, Erc20BalanceDeltaGteSpecSchema, NativeBalanceDeltaGteSpecSchema, ); // The continuation plan returned to the caller for a self-serve (manual-relay) // settle node: the next Flow to run plus a readiness `check`. The caller polls // the `check`, then re-submits `nextFlow` through the normal `/compose` // pipeline once it passes. The `check` is also embedded into `nextFlow` as an // on-chain assertion, so a premature re-submission reverts instead of executing // on stale balances. `FlowSchema` lives in the same package, so importing it // here keeps `compose-spec` dependency-free. export const ContinuationPlanSchema = Schema.Struct({ chainId: ChainIdSchema, nextFlow: FlowSchema, check: CheckSchema, validity: Schema.Struct({ expiresAtMs: Schema.Number }), correlationId: Schema.optional(Schema.String), }); export type ContinuationOutcome = typeof ContinuationOutcomeSchema.Type; export type ContinuationIntent = typeof ContinuationIntentSchema.Type; export type Phase1Artifact = typeof Phase1ArtifactSchema.Type; export type Check = typeof CheckSchema.Type; export type AssertionSpec = typeof AssertionSpecSchema.Type; export type ContinuationPlan = typeof ContinuationPlanSchema.Type; // The comparison operator carried by a `call` check. Mirrors the inline // `comparator` literal on the `call` member of `RawCheckSchema` above. export type CheckComparator = 'gte' | 'eq' | 'lte'; // Comparator predicates for `call` checks, matching the package's `is*` // convention (see `flow.ts`). Each is only true for a `call` check with the // corresponding comparator; the other check kinds carry no comparator. export const isGTECheck = (check: Check): boolean => check.kind === 'call' && check.comparator === 'gte'; export const isLTECheck = (check: Check): boolean => check.kind === 'call' && check.comparator === 'lte'; export const isEQCheck = (check: Check): boolean => check.kind === 'call' && check.comparator === 'eq';