UNPKG

@lifi/compose-spec

Version:

Public wire-format types and schemas for Compose flows

175 lines (160 loc) 6.84 kB
import type { Resource } from './resource.js'; /** * Per-chain native-asset semantics. * * Most EVM chains have an ETH-style native gas coin sent via `msg.value` (the * `evm-native` policy). A small number — currently only Tempo — express their * "native" coin as an ERC-20 token (the `as-erc20` policy). Code that needs to * lower or simulate a `Resource` of kind `"native"` should consult * `getChainCapabilities(chainId)` rather than hardcoding chain IDs. * * The compose compiler uses this registry to transparently normalise native * resources into ERC-20 resources on `as-erc20` chains at schema-decode time * (see `normalizeResource` below and the `ResourceSchema` transform in * `flowSchema.ts`). * * This union is the extension point for future native-asset policies. A chain * with no canonical native asset would get a `reject-native` arm, under which a * `{ kind: "native" }` resource becomes a validation error rather than being * rewritten. No such chain exists yet, so the arm is not defined here. */ export type ChainCapabilities = | { readonly kind: 'evm-native'; readonly chainId: number } | { readonly kind: 'as-erc20'; readonly chainId: number; readonly nativeErc20: NativeErc20; }; export interface NativeErc20 { readonly token: `0x${string}`; readonly symbol: string; readonly decimals: number; } /** * The zero-address sentinel that represents the native gas coin on * EVM-native chains. Exported so tests and call-sites can reference it * by name rather than inlining 40 zeros. * * Inlined as a literal rather than imported from `@lifi/utils`: `compose-spec` * is the wire-format leaf package and carries no `@lifi/*` dependencies * (enforced by `scripts/check-compose-boundaries.mjs`). */ export const EVM_NATIVE_SENTINEL: `0x${string}` = // eslint-disable-next-line @lifi/prefer-utils-constants -- compose-spec is the wire-format leaf and must not import @lifi/utils (enforced by scripts/check-compose-boundaries.mjs); the literal is intentional here '0x0000000000000000000000000000000000000000'; /** * Whether `address` is the EVM-native zero-address sentinel, compared * case-insensitively. Module-local: the two call-sites below share this exact * comparison; it is not part of the package's public surface. */ const isNativeSentinel = (address: string): boolean => address.toLowerCase() === EVM_NATIVE_SENTINEL.toLowerCase(); export const TEMPO_CHAIN_ID = 4217; export const TEMPO_NATIVE_ERC20: NativeErc20 = { token: '0x20C0000000000000000000000000000000000000', symbol: 'pathUSD', decimals: 6, }; const REGISTRY: ReadonlyMap<number, ChainCapabilities> = new Map< number, ChainCapabilities >([ [ TEMPO_CHAIN_ID, { kind: 'as-erc20', chainId: TEMPO_CHAIN_ID, nativeErc20: TEMPO_NATIVE_ERC20, }, ], ]); export const getChainCapabilities = (chainId: number): ChainCapabilities => REGISTRY.get(chainId) ?? { kind: 'evm-native', chainId }; export const isAsErc20Chain = (chainId: number): boolean => getChainCapabilities(chainId).kind === 'as-erc20'; /** * The address that represents the chain's native asset. * * On EVM-native chains this is the zero-address sentinel `0x0…0`. On * `as-erc20` chains it is the chain's stand-in ERC-20 (e.g. PathUSD on Tempo). */ export const canonicalNativeAddress = (chainId: number): `0x${string}` => { const caps = getChainCapabilities(chainId); return caps.kind === 'as-erc20' ? caps.nativeErc20.token : EVM_NATIVE_SENTINEL; }; /** * Whether `address` represents the native asset of `chainId`. * * On EVM-native chains, this is the case-insensitive zero-address check. On * `as-erc20` chains, the literal zero address returns `false` — only the * chain's stand-in ERC-20 address matches. */ export const isNativeAddressForChain = ( chainId: number, address: string, ): boolean => { const caps = getChainCapabilities(chainId); const target = caps.kind === 'as-erc20' ? caps.nativeErc20.token : EVM_NATIVE_SENTINEL; return address.toLowerCase() === target.toLowerCase(); }; /** * Whether `address` on `chainId` is the genuine `msg.value`-style native coin: * an `evm-native` chain's zero-address sentinel. * * This is the single predicate that gates `msg.value` semantics — choosing a * `NativeBalance` over an `Erc20Balance` simulation requirement, and skipping * the ERC-20 approval a native input would otherwise need. It returns `false` * for every address on an `as-erc20` chain (including the zero sentinel), * because there the "native" coin is a real ERC-20 moved via `transferFrom`, * never `msg.value`. Use this rather than re-deriving * `!isAsErc20Chain(chainId) && address === EVM_NATIVE_SENTINEL` at call sites, * and rather than `isNativeAddressForChain`, which returns `true` for the * stand-in ERC-20 (PathUSD) on `as-erc20` chains and would wrongly route it to * `msg.value`. */ export const isMsgValueNative = (chainId: number, address: string): boolean => !isAsErc20Chain(chainId) && isNativeSentinel(address); /** * Rewrite a single `Resource` according to its chain's capabilities. * * On chains with the default `evm-native` policy, every resource is returned * unchanged. On `as-erc20` chains (currently only Tempo, chainId 4217), a * resource of kind `"native"` is rewritten into the equivalent ERC-20 * resource pointing at the chain's stand-in token (PathUSD for Tempo). * * This is the rewrite primitive behind the `ResourceSchema` decode transform * (see `flowSchema.ts`), which is THE chokepoint: it runs every time a * `Resource` is parsed — flow inputs via `FlowSchema`, op configs via * `decodeConfig` — so every downstream pass (`normalizeFlow`, `resolve`, * `lower`, `simulate`) sees only ERC-20 resources on `as-erc20` chains. * * The function preserves referential identity when nothing changes (returns the * input `r` unchanged on evm-native chains and for ERC-20 resources) and is * idempotent: running it on an already-rewritten resource is a no-op. */ export const normalizeResource = (r: Resource): Resource => { if (r.kind !== 'native') return r; const caps = getChainCapabilities(r.chainId); if (caps.kind !== 'as-erc20') return r; return { kind: 'erc20', chainId: r.chainId, token: caps.nativeErc20.token, }; }; /** * Address-level counterpart of `normalizeResource` for wire fields that carry a * native asset as a bare token string (a continuation outcome's `token`). On * `as-erc20` chains the native sentinel is rewritten to the chain's stand-in * ERC-20; every other address passes through. */ export const normalizeNativeTokenAddress = ( token: string, chainId: number, ): string => isAsErc20Chain(chainId) && isNativeSentinel(token) ? canonicalNativeAddress(chainId) : token;