@lifi/compose-spec
Version:
Public wire-format types and schemas for Compose flows
279 lines (257 loc) • 12.3 kB
text/typescript
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';