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