npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@eco-foundation/api-schemas

v0.7.0

Published

Zod-first schemas, error catalog, golden fixtures, and generated OpenAPI 3.1 for the Eco /v1 API

Readme

@eco-foundation/api-schemas

Zod-first schemas, error catalog, golden fixtures, and generated OpenAPI 3.1 for the Eco /v1 API. The Zod schemas are the source of truth; everything else (OpenAPI, fixtures, plain types) is generated from or gated against them.

Today the package covers 13 endpoints across 12 paths, 39 error codes (51 namespaced legacy references), and 19 golden fixtures.

Install

While the package is 0.x, pin the patch range or the exact version:

{
  "dependencies": {
    "@eco-foundation/api-schemas": "~0.7.0"
  }
}

Never write ^0. npm resolves ^0.x as >=0.0.0 <1.0.0, so it silently accepts every future breaking 0.y — it is not a pin at all. Use ~0.7.0 or the exact 0.7.0.

A matching version string is not evidence of a matching artifact: also compare V1_SCHEMA_DIGEST (see Invariants). Two builds both called 0.4.0 — a registry copy and a file: tarball, say — can carry different schemas.

zod is a peer dependency, and since 0.3.1 an optional one — required only if you import a zod-bearing entry point. Which case you are in is decided entirely by your imports; see Consumers.

Migrating 0.6.0 -> 0.7.0

BREAKING under 0.x semver (breaking bumps the minor while the major is 0). Nothing on the wire that 0.6.0 accepted is rejected, and nothing 0.6.0 published is removed: two operations RETURN, one response field and one error code are ADDED. Three things still break, and one of them is a live-behaviour change rather than a compile error — read all three before bumping.

V1_SCHEMA_DIGEST MOVES. Both digest inputs changed (errors/catalog.lock.json gained one code; openapi/v1.json gained two operations, two request schemas and one response field), so the value is now 3e13c5eeb8b054f3a48a22c7694de16b0fc11e1dbf6bc3c27e8c575100225b2a — was a6724fa4dd585e77aebe680655ac10fe5a2f4b70537930a0b4dee2612afb7c8e on 0.6.0. Every consumer running assertNoSkew must move to the new value in the same rollout; a mixed fleet fails the check by design.

THE THREE BREAKING ITEMS FIRST.

1. LaneStandard widens back to all four standards — a compile break for anyone keying on it. STANDARDS_BY_TARGET.quote is ['erc-3009', 'permit2', 'permit3'] (depositAddress is unchanged), and LaneStandard is derived from the matrix, so it is now 'erc-3009' | 'permit2' | 'permit3' | 'erc-2612' — the exact mirror of 0.6.0's narrowing. V1_SUBMIT_SCHEMAS: Record<LaneStandard, …> regains its permit2/permit3 entries, and V1_SUBMIT_SCHEMAS_BY_SERVICE.intents has three lanes. Your own Record<LaneStandard, X>, and every exhaustive switch over a LaneStandard, stops compiling until it gains two arms. That is the intended signal: the alternative is a dispatcher that meets undefined at runtime. A consumer that only indexes V1_SUBMIT_SCHEMAS_BY_SERVICE['deposit-addresses'] is untouched.

2. A bare dependency bump WIDENS what a server ADVERTISES. A router that derives its /v1/tokens supports.quote from STANDARDS_BY_TARGET advertises permit2 and permit3 the moment this version is installed — before it can necessarily execute them. That is exactly the false promise 0.6.0 closed. Ship the bump in the same release as the lane implementation (quoted-salt dispatch, execution.vault publication, and the vault binding described under the additive items), or gate discovery separately. This is the one item here that changes live behaviour with no code change on your side.

3. Consumer-side, but read it: the lock gained a 400. V1ProblemSchema cross-checks a KNOWN code's status/type against the catalog. vault-mismatch (below) is now known, so a body carrying that spelling with a contradicting status or type stops parsing where 0.6.0 tolerated it as "a newer server". An exhaustive switch over V1ErrorCode will not compile until it handles it.

THE ADDITIVE ITEMS.

Returned — POST /v1/intents/submit/permit2 and /permit3. This supersedes 0.6.0's breaking item 4. The lanes can complete now because the two things that item named as missing exist: the router dispatches the QUOTED route.salt unchanged (so the intent it funds is the intent it quoted), and the quote response publishes the intent vault that salt produces (execution.vault, below), so a signer can name the vault-bound counterparty before signing. Both operations, both V1_ENDPOINTS entries (intents.submit.permit2, intents.submit.permit3), both golden fixtures, and the five schema exports Permit2PermitSingleSchema, Permit2SubmitSchema, Permit3AllowanceSchema, Permit3PayloadSchema, Permit3SubmitSchema are back. Path order in the document is erc-3009, permit2, permit3 — the matrix order — and the tokens fixture advertises the same three under supports.quote.

The bodies are NOT the 0.5.0 bodies. A consumer skipping 0.6.0 (0.5.0 -> 0.7.0) sees SIX newly-required fields, and a 0.5.0 body is a 400 at these paths. This is the material the solver's DTOs require and verify, which the 0.5.0 shapes omitted:

 // POST /v1/intents/submit/permit2
 {
+  "chainId": 8453,
   "target": { "quoteId": "quote:…" },
   "permit2": {
     "details": { "token": "0x…", "amount": "1043221", "expiration": 1767225600, "nonce": 0 },
-    "spender": "<anything>",
+    "spender": "<execution.vault of the quote>",
-    "sigDeadline": 1767225600
+    "sigDeadline": "1767225600"
   },
   "signature": "0x…"
 }
 // POST /v1/intents/submit/permit3
 {
   "target": { "quoteId": "quote:…" },
   "permit3": {
     "owner": "0x…",
+    "permitContract": "0x…",
     "salt": "0x…",
     "deadline": 1767225600,
+    "timestamp": 1767222000,
+    "merkleRoot": "0x…",
     "permits": [
-      { "chainId": 8453, "token": "0x…", "amount": "1043221" }
+      { "chainId": 8453, "token": "0x…", "amount": "1043221",
+        "account": "<execution.vault of the quote>", "modeOrExpiration": 0 }
     ]
   },
   "signature": "0x…"
 }
  • permit2 chainId (top-level, required) is the EIP-712 domain chainId the signature was produced under and the chain the permit executes on; the solver verifies the two agree. It follows the C4 convention erc-3009 and erc-2612 already use.
  • permit3 has NO top-level chainId, deliberately: its EIP-712 domain is not the source chain, and each leg names its own chain. A consumer that needs one chain id for the body takes permits[0].chainId. It also carries no leaves and no tokenKey — the solver recomputes the merkle tree from the legs, and tokenKey is pad(token, 32) (ERC-20 only) by definition.
  • permit3 permitContract, timestamp, merkleRoot are the signer's values, never synthesized: the solver recomputes the root from the legs and rejects a mismatch.
  • permit3 leg account and modeOrExpiration: account is where the leg pushes funds (mode 0, immediate transfer) or grants the allowance (any other value, read as the allowance expiration in Unix seconds). On a quote target it is always the vault.
  • nonce, expiration, timestamp and modeOrExpiration are Uint48, a new primitive (with UINT48_MAX, 2^48 - 1): the fields are uint48 on-chain and the ABI encoder truncates rather than rejects, so the edge does. Expressed as bounds so the ceiling reaches the OpenAPI document.
  • permit2 sigDeadline is a decimal STRING (UintString), and details.expiration a Uint48 — both carry their on-chain width instead of UnixSeconds. The Permit2 SDK's MaxAllowanceExpiration (2^48-1) and MaxSigDeadline (2^256-1) are the standard "no expiry" idiom many wallets sign, and a <=2100 bound rejected a valid signature that would have funded. Same narrow exception erc-3009's validAfter/validBefore carry; a NUMBER sigDeadline is a 400 at permit2.sigDeadline. Every other deadline in the module (permit3.deadline, erc-2612's) is still UnixSeconds and still rejects a milliseconds value.
  • Permit3PayloadSchema stays a plain stripping z.object. A permit3.signature sent in the wrong place is dropped, so a reader has exactly one place to find the signature.

What the schema does NOT check, and the router does. The body carries no vault, so the vault binding cannot be a refinement here (refineDepositAddressBinding is a no-op on quote targets by construction). Before the signature pin the router asserts, per lane: the signed counterparty (permit2.spender, every permits[].account) equals the quote's execution.vault — compared CASE-INSENSITIVELY (isSameEvmAddress, exported here), since neither side is checksum-normalized and a naive === turns a checksummed vault against a lowercase signature into a spurious 400 — else vault-mismatch (400); every permit3 leg chainId and the permit2 chainId equal the quote's source chain; the token equals the quote's source token and the amount is at least the reward amount for it; permit2.details.expiration, permit2.sigDeadline, permit3.deadline and a non-zero modeOrExpiration are all more than 60 seconds in the future (a stale authorisation is a 400, not a reverting funding batch). A green parse is not a green submit.

Added — execution.vault on every quote object (optional, nullable, AnyAddress). The intent vault for THIS quote: the CREATE2 address the source-chain Portal funds the intent through, derived from the quoted intent (route.salt included), so it changes whenever the salt does. It is the value to sign as erc-3009 authorization.to, permit2 spender and every permit3 leg account. Absent means the producer predates the field; null means the producer could not derive it — today that is any non-EVM source (an SVM vault is a PDA and is not published). AnyAddress rather than EvmAddress because the same envelope carries SVM funding transactions and a future SVM producer must be able to publish the PDA here without a shape break; as always, never infer a VM from the branch that matched. Each ranked quotes[] entry carries its own. The zod-free V1Execution alias gains vault?: string | null, and the parity spec pins the optional member explicitly (an optional key is the one drift the mutual-assignability gate cannot see through a looseObject's index signature).

Added — vault-mismatch (400): the signed counterparty is not the quote's intent vault. The quote-target twin of deposit-address-mismatch, which is the wrong NAME on a quote target (there is no deposit address) — and invalid-parameter says nothing a caller can act on. audience: 'write' — like the twin it needs a signed counterparty, so only a submit can emit it — and legacyCodes: [] (no legacy service ever compared against a vault). A router's detail names the field (permit2.spender, permit3.permits[1].account, authorization.to), never the signed address. Branch on code: it shares 400 with invalid-request.

Migrating 0.5.0 -> 0.6.0

BREAKING under 0.x semver (breaking bumps the minor while the major is 0). Five items REJECT a request 0.5.0 accepted or remove something 0.5.0 published; four are additive. Every breaking item is listed here — if you send anything named below, read its entry before bumping.

V1_SCHEMA_DIGEST MOVES. Both digest inputs changed (errors/catalog.lock.json gained one code; openapi/v1.json lost two operations, gained two parameters and several descriptions), so the value is now a6724fa4dd585e77aebe680655ac10fe5a2f4b70537930a0b4dee2612afb7c8e — was 1d8e88e957795b23e1317dc1e3783fd64bf79b756e3f28bd40e02b047542d790 on 0.4.0, 0.4.1 and 0.5.0. Every consumer running assertNoSkew must move to the new value in the same rollout. Unlike 0.5.0, this time the skew check DOES see the difference, so a mixed fleet fails it by design rather than silently validating two ways.

THE FIVE BREAKING ITEMS FIRST.

1. slippage now rejects 0 and anything below 0.0001, and the rejection is named.

// 0.5.0: parses (and then fails downstream). 0.6.0: 400, code slippage-out-of-bounds, path ['slippage'].
{ ..., slippage: 0 }
{ ..., slippage: 0.00005 }

The range is 0.0001-1 (SLIPPAGE_MIN/SLIPPAGE_MAX, both exported), a DECIMAL FRACTION of the destination amount: 0.005 is 0.5%. No solver can express a tolerance below one basis point, so a 0 could only ever be rejected downstream, silently clamped, or silently replaced by a default; refusing it at the edge with a named code is the honest one. Zero tolerance is meaningless on a swap route and ignored on a non-swap route, so nothing is lost.

The code changes for EVERY out-of-range value, not only the new floor. 0.5.0 enforced the bound with a built-in .min()/.max(), which zod reports as too_small/too_big — and v1ErrorCodeFromIssues maps only a custom issue carrying v1IssueParams, so the catalog's slippage-out-of-bounds was promised and unreachable: 50 rendered as the generic invalid-request with legacyCode: eco-quotes:1003. It now renders as slippage-out-of-bounds (status 400 unchanged, detail text unchanged, no legacyCode because that code has none). An integrator branching on code === 'invalid-request' for a bad slippage, or reading legacyCode there, sees the change. The catalog TITLE names the floor now; code, status and legacyCodes are untouched.

The published document also carries the units for the first time: both slippage fields have a description, and the request field keeps minimum/maximum (via .meta(), since the bound itself lives in a refinement that z.toJSONSchema drops). The response slippage is documented as the tolerance ACTUALLY bound into destination.minAmountOut — when a request omits slippage, that is the default the solver applied, not the omission.

2. A zero amount is rejected on the quote request.

// 0.5.0: parses, reaches a solver, comes back as a 502. 0.6.0: 400, path ['source','amount'].
{ ..., source: { chainId: 8453, token: '0x…', amount: '0' } }

source.amount and destination.amount are PositiveUintString (new primitive: UintString with '0' excluded, message must be greater than 0). Every other UintString field is unchanged — a zero value on a destination call or a zero fee is still legal. The bound reaches the OpenAPI document as a description only; a string has no minimum.

3. options.maxCallDataSize is gone.

// 0.5.0: parses. 0.6.0: 400, unrecognized_keys at path ['options'].
{ ..., options: { maxCallDataSize: 1000 } }

It was published with no description and read by no service, so a caller sending it was handed a 200 for a bound nobody enforced. The strict options object now names the key in a 400, the same disposition allowHighSlippage has had since 0.3.0. The zod-free V1QuoteRequest alias drops the member too. A caller that needs a calldata cap needs one that is defined and enforced, which is a different change.

4. POST /v1/intents/submit/permit2 and /permit3 are WITHDRAWN. (Superseded by 0.7.0, which returns both lanes with the fields named below — see that section.)

Neither lane can complete, and this is why. The signed Permit2 spender and every Permit3 leg account must be the intent VAULT (Portal.fundFor pulls the funds through IPermit.transferFrom(funder, vault)), and the vault is derived from the route salt the router dispatches — a value the signer cannot know before signing until quoted-salt dispatch and vault binding exist. On top of that the published bodies omitted material the solver requires (permit2 chainId; permit3 permitContract, timestamp, merkleRoot, per-leg account / modeOrExpiration), so the first refusal was a 401 on every request. Advertising a lane whose every body ends in a 4xx is a false promise. The lanes return as a follow-on once quoted-salt dispatch and vault binding land; erc-3009 is the quote-target intents standard until then.

What moved, concretely:

  • STANDARDS_BY_TARGET.quote is ['erc-3009']. depositAddress is unchanged.
  • The two registry entries, the two operations in openapi/v1.json, and the two golden fixtures are gone (11 endpoints over 10 paths; 17 fixtures). A router that dispatches from V1_SUBMIT_SCHEMAS_BY_SERVICE.intents answers invalid-parameter on those routes by construction.
  • Five exports are deleted: Permit3AllowanceSchema, Permit3PayloadSchema, Permit3SubmitSchema, Permit2PermitSingleSchema, Permit2SubmitSchema. No endpoint accepts those bodies, so there is nothing for them to validate.
  • V1_SUBMIT_SCHEMAS is keyed by the new LaneStandard type ('erc-3009' | 'erc-2612' — the standards that have a lane), not by SubmitStandard. V1_SUBMIT_SCHEMAS[standard] with a SubmitStandard-typed index no longer compiles; narrow to LaneStandard first. That is the intended signal — the alternative was an undefined a dispatcher trips over at runtime.
  • SUBMIT_STANDARDS and SubmitStandard KEEP all four spellings. That enum is also the discovery vocabulary a token advertises under supports, and narrowing it would make a consumer hard-fail on a token list from a server still spelling permit3. The vocabulary is what a token MAY advertise; the matrix is what has a lane. The tokens fixture advertises quote: ['erc-3009'].

5. Consumer-side only, but read it: the lock gained a 502. V1ProblemSchema cross-checks a KNOWN code's status/type against the catalog. solver-error (below) is now known, so a body carrying that spelling with a contradicting status or type stops parsing where 0.5.0 tolerated it as "a newer server". An exhaustive switch over V1ErrorCode will not compile until it handles it.

THE FOUR ADDITIVE ITEMS.

Added — solver-error (502): an upstream solver rejected or failed the quote request. solver-timeout was the only 502, so a router had to label a solver answering 400 in six milliseconds with a title that says "timed out". solver-error is the code for every upstream failure that is NOT a deadline expiry — a solver 4xx/5xx, a network error, an unparseable body. It is 502 rather than 4xx/422 on purpose: the gateway built the RFQ, so a solver rejection is a gateway/contract fault, never the caller's, and a 422 mapping would hide a contract bug as "no route". legacyCodes is EMPTY by construction: eco-quotes:1025 (Failed) is the ref that would belong here, but it was published on solver-timeout and the lock is append-only, so it stays there. Branch on code, not on status, to tell the two 502s apart.

Added — ?chainId= on GET /v1/chains and GET /v1/tokens. OPTIONAL on both (the unfiltered document is unchanged and stays legal), a single value only. A malformed value — abc, 0, -1, 1.5, or a repeated ?chainId=1&chainId=8453 — is a 400 at the field; a syntactically valid chain id that matches nothing is a 200 with chains: [] / tokens: [], nextCursor: null, never a 404. New exports: CHAINS_LIST_QUERY_SHAPE / ChainsListQuerySchema, TOKENS_LIST_QUERY_SHAPE / TokensListQuerySchema, and ChainIdParam (the coercing query-string twin of the body ChainId, which all three chainId query params — these two and the deposit-address lookup's — now wrap, so the accepted set cannot differ between endpoints publishing the same parameter name). Query-side only: never coerce a chain id in a JSON body.

Added — renderIssuesAsProblemParts(issues, opts?) -> { code, detail?, errors? }, THE one sanctioned path from a ZodError to a problem body. Two services rendered the same validation failure with a cap of 4 and a cap of 10, a (+N more issues) suffix and an N issues, first M: prefix, a bare root message and a (root) placeholder, and only one of them emitted errors[]. This function is the single set of conventions: cap MAX_DETAIL_ISSUES (8, now exported) for both detail and errors; overflow is ONE trailing (+N more issues); a root-level issue is its bare message; errors carries bare dotted fields and is omitted when nothing survives. Absent keys are absent, so the result spreads straight into problem(code, rest) or your own builder. problemFromZodError is built on it and a test pins that the two produce byte-identical bodies. A service with its own problem type should route every ZodError through here and delete its local cap and overflow constants.

Added — execution detail on a status entry. StatusEntrySchema and statusEntrySchemaFor gain three OPTIONAL members: sourceTx and destinationTx as { chainId, txHash, token, amount }, and steps[] as { type: 'SWAP' | 'BRIDGE', status, from, to, transactions: { created?, fulfilled?, refunded? } } with { token, amount, chainId } legs and { chainId, txHash } transaction refs. The shape mirrors the solver's step facts verbatim; the step type is the producer's vocabulary (not the quote-response StepKind) and the step status is the producer's raw per-step state beneath the C1 entry-level vocabulary, deliberately unmapped. Absent means unknown; null is rejected — a null would read as "known to be none" on a lane whose upstream simply has no token/amount. Today the quote-status lane can populate them; the general intent-status lane omits them. chainId is a JSON number (the solver spells it as a string; the producer converts), amounts are uint strings, token is a bare string because the native asset may be a sentinel. New schema exports StatusTxRefSchema, StatusTokenAmountSchema, StatusTxDetailSchema, StatusStepSchema, StatusStepType / STATUS_STEP_TYPES; new zod-free aliases V1StatusTxRef, V1StatusTokenAmount, V1StatusTxDetail, V1StatusStep, V1StatusStepType.

Migrating 0.4.1 -> 0.5.0

BREAKING under 0.x semver (breaking bumps the minor while the major is 0): this release REJECTS a request body 0.4.1 accepted. Nothing else moved — no code, status, legacyCodes mapping, or response shape changed.

V1_SCHEMA_DIGEST DOES NOT MOVE, and that is the caveat to read before rolling out. The new rule is a superRefine, and z.toJSONSchema drops refinements, so neither digest input changed: openapi/v1.json is byte-identical and assertNoSkew cannot see the difference. A fleet running 0.4.1 and 0.5.0 side by side therefore passes the skew check while VALIDATING DIFFERENTLY — the same body is a 400 on one instance and a 200 on another. Roll every consumer together, or accept that inconsistency knowingly.

Rejected — a funder that cannot share a chain with source.token.

// 0.4.1: parses. 0.5.0: 400, path ['funder'].
{ source: { chainId: 1399811149, token: '<a Solana mint>' }, funder: '0x3333…3333' }

Both fields live on the SOURCE chain, so an EVM funder beside a Solana source token describes no chain that exists. It used to parse, get quoted, and then fail wherever a service classified the funder against the source VM — and a service that read a malformed funder as ITS OWN material answered 500 internal-error for a field the caller sent.

What this rule deliberately does NOT do: bind funder to source.chainId. That needs a chain-ID-to-VM table, and this package holds none by design — ChainId is any positive integer, and ADDRESS_BY_CHAIN_TYPE exists for the caller that already knows the chain type. A table here would make every published version an allow-list of chains: a newly launched SVM chain would be misclassified by every consumer still on an older pin, and its valid base58 funder rejected, so this package would have to be re-released and rolled out before a chain could launch. Comparing two addresses from the same request needs no table and cannot go stale.

So the check is a NECESSARY condition, not a sufficient one, in two specific ways:

  • Two EVM addresses agree with each other whatever chainId says, including a Solana chain id. A green parse is not evidence the funder is right for the chain.
  • SVM and TVM are not separated. A Tron base58check address also satisfies SvmAddress's 32-44 character range (see the AnyAddress docblock), so shape cannot decide between them and the rule lets that pair through rather than guessing.

A service that knows the chain's VM must still check each address against it. Two new exports help: addressFamilies(value) returns every family a value is consistent with, and addressFamiliesCanAgree(a, b) is the pairwise predicate the refinement uses.

Migrating 0.4.0 -> 0.4.1

Purely additive; no schema, wire, or digest change. V1_SCHEMA_DIGEST is UNCHANGED, so this is not a coordinated-rollout release — a fleet may run 0.4.0 and 0.4.1 side by side.

Added — the two conformance skip reasons are exported constants. SIDE_EFFECT_SKIP_REASON and DRAFT_SKIP_REASON are now named exports of the ./testing subpath. They were previously inline literals in this package's own spec, which meant a consumer pinning them had to copy the strings and had no way to notice a rewording — the published tarball ships dist/, never the sources those literals lived in. Import them instead of copying:

import { DRAFT_SKIP_REASON, SIDE_EFFECT_SKIP_REASON } from '@eco-foundation/api-schemas/testing';

This is also the first release cut from eco/eco-api-schemas, the package's own repository. It previously shipped from a pnpm workspace inside eco-incorp/router; nothing about the published artifact's contents or layout changed with the move.

Migrating 0.3.1 -> 0.4.0

0.3.1 was never published, so 0.4.0 is the upgrade from 0.3.0 and carries 0.3.1's change as well. It is a BREAKING release under 0.x semver (breaking bumps the minor while the major is 0), and it carries ONE breaking change plus two additive ones. The break is scoped to consumers that read the SVM branch of a quote's funding transaction; EVM quotes are byte-identical to 0.3.0. Nothing here rejects a request body 0.3.0 accepted, and no existing code, status, or legacyCodes mapping moved.

V1_SCHEMA_DIGEST MOVES. Both digest inputs changed (errors/catalog.lock.json gained four codes, and openapi/v1.json was regenerated for both the instruction list and the new response member), so the value is now 1d8e88e957795b23e1317dc1e3783fd64bf79b756e3f28bd40e02b047542d790 — was 3d41b4893b54e77e9ea56c24af1d7a1e9de0685ee911e497834dcd746e72c1f6 on 0.3.0/0.3.1. Every consumer running assertNoSkew must move to the new value in the same rollout: the check compares services against each other, so a fleet running two versions fails it by design.

THE BREAKING HALF FIRST (PAR-576), then the two additive ones (PAR-601, PAR-605).

The SVM funding transaction is now an ORDERED LIST OF INSTRUCTIONS, not a serialized transaction. SvmTransactionSchema dropped serializedTransaction and gained feePayer and instructions:

-{ "kind": "svm", "chainId": 1399811149, "serializedTransaction": "<base64 tx>" }
+{
+  "kind": "svm",
+  "chainId": 1399811149,
+  "feePayer": "<base58 pubkey>",
+  "instructions": [
+    {
+      "programId": "<base58 pubkey>",
+      "accounts": [{ "pubkey": "<base58>", "isSigner": true, "isWritable": true }],
+      "data": "<base64: 8-byte Anchor discriminator + Borsh args>"
+    }
+  ]
+}

serializedTransaction is not deprecated-but-accepted; the key is gone and a body carrying it fails QuoteResponseSchema. That is deliberate — response schemas are looseObject, so leaving the old key valid would let a consumer keep reading a field the server no longer fills and get undefined at signing time instead of a named rejection.

Why the break, since 0.3.1 never shipped a working SVM quote: a serialized Solana transaction embeds a recent blockhash, valid for roughly 150 slots — 60 to 90 seconds. Every realistic caller flow (fetch the quote, render it, let a human review it, sign, submit) is longer than that, so the transaction was already dead when the caller signed it, the failure surfaced as an opaque BlockhashNotFound, and the schema carried no field with which to refresh it. The old shape was undeliverable rather than merely awkward. Instructions have no expiry: the caller fetches its own blockhash at signing time, which is how every Solana wallet already works.

What the caller now owns. In order:

  1. Fetch a recent blockhash (or use a durable nonce).
  2. Compile instructions in the given order into a legacy or v0 message, with feePayer as the fee payer.
  3. Sign with every key marked isSignerfeePayer is always one of them.
  4. Choose its own compute-unit limit and priority fee. None is prescribed.

Two contracts that are load-bearing, and that no schema can check for you:

  • instructions[].accounts is POSITIONAL. Solana resolves an instruction's accounts by INDEX. Pass the array through verbatim — do not sort it, deduplicate it, drop an account that also appears in another instruction, or regroup signers and writables. A permuted list is a different instruction that still validates, and it fails on-chain with an unhelpful message (a permuted Eco Portal fund reports InvalidVault, InvalidAta or InvalidTokenTransferAccounts depending on which pair swapped). The schema does not know which program programId is, so it cannot police this; it validates each entry's shape and the PRESENCE of both privilege flags, and nothing more.
  • instructions may exceed one transaction, and that is not an error. A public-visibility quote emits Portal.publish (carrying the whole route preimage) plus Portal.fund, and Solana's packet ceiling is 1232 bytes: an EVM-destination route with one token and one call ABI-encodes to about 600 bytes, so publish alone is roughly 710 bytes of instruction data, fund adds about 173, and ten unique pubkeys add about 320 — over the ceiling before headers. The instructions are independent on-chain, so submit them in the given order across two transactions, or compile a v0 message against your own address-lookup table. Splitting is safe; reordering is not.

No address-lookup-table references are emitted. An ALT must already exist on-chain and eco-router has no Solana RPC with which to create or read one, so every account is inline and the message compiles as legacy. Bring your own ALT if you want a v0 message; the account metas are unaffected by that choice.

isSigner and isWritable are REQUIRED booleans on every account meta, never optional and never defaulted. Both directions of a wrong value are a real failure — a missing isWritable makes the program's write revert, a missing isSigner makes Anchor's Signer constraint reject the instruction — so absence is a 400 rather than a guess.

New, additive: the Base64Bytes primitive (standard padded base64; URL-safe, unpadded, and whitespace-bearing values are rejected, because Node's decoder silently accepts all three and strict decoders do not), plus the SvmAccountMetaSchema / SvmInstructionSchema schemas and the zod-free V1SvmAccountMeta / V1SvmInstruction aliases.

Unchanged: the EVM branch, every request schema, and the error catalog — it gained NO new codes from this change.

TVM STAYS UNREPRESENTABLE, AND THAT IS NOW A DECISION RATHER THAN AN OPEN QUESTION. Tron is OUT OF SCOPE for /v1 funding. FundingTransactionSchema has no tvm member, a kind: "tvm" transaction fails QuoteResponseSchema, and eco-router answers chain-not-supported (422) for a Tron-source funding quote. Do not read the SVM lane as a precedent that a tvm branch is queued behind it: adding one would be a breaking wire change with its own migration note, and nothing here commits /v1 to that surface.

Added — ./package.json is an exported subpath. require('@eco-foundation/api-schemas/package.json') previously threw ERR_PACKAGE_PATH_NOT_EXPORTED, which broke tooling that reads a dependency's manifest for its version. Nothing else changes; it is purely additive. (prepack now also rebuilds dist/ on a manual npm pack/npm publish, which affects publishing rather than consuming.)

Added — the errors problem extension (per-field caller faults).

{
  "type": "https://.../invalid-request",
  "title": "Invalid request",
  "status": 400,
  "code": "invalid-request",
  "detail": "source.chainId: must be a supported chain id",
  "errors": [{ "field": "source.chainId", "detail": "must be a supported chain id" }]
}

errors is OPTIONAL: an invalid-request for a body that is not JSON at all has no field to name, so absence is legal and means exactly that. field names the offending input in YOUR terms — the path into what you sent, with no body./query. prefix: source.chainId, funder, limit. detail obeys the same free-text policy as the problem's own detail.

Because there is no prefix, field alone does not say whether the value came from the body or the query string. Every /v1 endpoint takes its parameters in exactly one of the two, so key on the route plus field, not on field alone.

errors is NOT solverErrors. They sit adjacent and answer different questions: errors is your own fault (a field you sent, and naming it is how you fix it); solverErrors is a downstream solver's failure, which you did not cause and usually cannot fix. A body never needs both to explain one failure. The name errors is the de-facto RFC 9457 validation extension (Spring ProblemDetail, ASP.NET ValidationProblemDetails), so it is probably already in your SDK.

problemFromZodError populates it from the issues it already renders into detail, so you get the machine-readable form for free — bounded to 8 entries, and an issue with no path is omitted rather than given a placeholder field.

Added — chains, REQUIRED on the deposit-address create response (PAR-571).

{
  "id": "deposit-address:0x…",
  "chains": [
    { "chainId": 8453, "source": true, "registration": "registered" },
    { "chainId": 137, "source": false, "registration": "registered" }
  ]
}

Four identifiers, two pairs, and the members of a pair are one word apart — pair them carefully, because the wrong pairing still compiles. DepositAddressChainRegistrationSchema is the ENUM and DepositAddressChainRegistration its inferred type; DepositAddressChainStatusSchema is the OBJECT that chains[] holds and DepositAddressChainStatus its inferred type. The Schema suffix is what distinguishes schema from type — the bare name is always the type.

REQUIRED on the create response, and absent from the shared record. DepositAddressCreateResponseSchema is now DepositAddressSchema.extend({ chains }) rather than a bare alias. If you read a create response, chains is always there; if you read a lookup page, it is never there. That is deliberate — an optional member on the shared record would make one key mean "bug" on one lane and "correct" on the other, with nothing in the type to distinguish them. Migration: a server build that returns 201 without chains now fails its own response schema.

.min(1): the source chain is always attempted, so an empty array could only mean the producer lost the outcome — and an empty chains reads as "no chain is live", the opposite of what a 201 means.

Two constraints are enforced beyond the shape: exactly one entry has source: true, and no chainId repeats. source is what tells you which chain the top-level depositAddress is definitely correct for, so zero or two source entries would be a silently wrong answer about where funds may go. Errors name the field and carry the count (chains must contain exactly one source entry, found 2); a duplicate is reported at the offending index.

These live in a superRefine, which means they do not appear in openapi/v1.jsonz.toJSONSchema drops refinements. Validate against the zod schema if you need them enforced; a client generated from the OpenAPI document alone will accept a body the zod schema rejects.

What .min(1) does NOT give you: it rejects an empty array, not an incomplete one. A schema cannot know which chains should be present. Completeness is a producer guarantee — deposit-addresses builds the array from the full attempted set — not something validated here.

Two things to read correctly:

  • registration is binary today. The enum exists so 'pending' can be added additively if a retry lane is ever built; there is no such lane, so do not look for a third state.
  • registration: "registered" means the chain-state ROW EXISTS. That row is what the polling services rebuild their watch set from at boot. It does NOT promise the in-process publish succeeded, so it can lead the live process by one restart. Read it as "known, and picked up no later than the next boot" — never "being watched right now". It is also not isDeployed, which is CREATE2 deployment of the address contract: isDeployed: false does not mean "do not send funds here".

New zod-free aliases: V1DepositAddressChainStatus and V1DepositAddressCreateResponse.

Added — four error codes for the transport statuses /v1 could not answer (PAR-601).

| Code | Status | Reached by | | ------------------------ | ------ | ------------------------------------------------------------ | | endpoint-not-found | 404 | a typo'd path — no /v1 endpoint at that path | | method-not-allowed | 405 | a real path, wrong HTTP method | | request-too-large | 413 | body bytes over the limit, or too many urlencoded parameters | | unsupported-media-type | 415 | an unsupported media type, charset, or content encoding |

All four are audience: 'all' and carry no legacyCodes. Before 0.4.0 these four statuses had no catalog code at all, so a /v1 response at any of them fell through to eco-router's legacy envelope — a partner SDK keying on type/code saw neither.

The one newly-possible rejection, and it is consumer-side. V1ProblemSchema cross-checks a KNOWN code's status/type against the catalog and ignores codes it does not know. These four are now known, so a problem body carrying one of them with a CONTRADICTING status or type stops parsing where 0.3.0 tolerated it as "a newer server". If you already emit any of these four spellings, confirm the status and type match the table above before upgrading.

endpoint-not-found is NOT token-not-found. Both are 404. A consumer branching on status alone will conflate "that path does not exist" with "that token is unknown on that chain" — branch on code.

Exhaustive switch over V1ErrorCode will not compile until you handle the four. That is the intended signal, not a break to work around.

Added — /v1/chains may advertise the deployment's effective page bounds (PAR-605).

{
  "chains": [{ "chainId": 8453, "...": "..." }],
  "limits": { "paging": { "maxLimit": 25, "defaultLimit": 10 } }
}

limits is OPTIONAL, and absence has a documented meaning: the published PagingFields bounds apply — cap STATUS_PAGE_MAX_LIMIT (50), default STATUS_PAGE_DEFAULT_LIMIT (20), which is exactly the 0.3.0 contract. So a server that does not populate it stays correct and a consumer needs no new branch. When limits IS present, paging is required inside it: limits: {} is rejected rather than read as "bounds advertised, none given".

An operator may only NARROW, so both numbers are capped at the canon values, and defaultLimit <= maxLimit is enforced — a deployment cannot advertise a first page it would reject on request.

Read it if you choose page sizes programmatically. The 400 that names the bound is unchanged; this exists so the bound is discoverable in one request rather than one rejection.

Scope — it is the bound of the service serving that document. eco-router serves /v1/chains and /v1/intents/status from one config, so its answer covers both. /v1/deposit-addresses/status and the /v1/deposit-addresses lookup are the deposit-addresses service's, paged from its own config; DepositAddressLookupResponseSchema deliberately does not carry this member.

Carried over from the unpublished 0.3.1: the zod peer is now optional. The range is unchanged (^4.3.0). It only widens the set of installs that resolve cleanly — a consumer importing only the zod-free entry points (./v1/signature, ./v1/types) no longer gets a permanently wrong unmet-peer warning. optional suppresses the warning only when zod is ABSENT: a consumer holding a zod outside ^4.3.0 still sees a version-mismatch warning.

Migrating 0.2.0 -> 0.3.0

0.3.0 is a BREAKING release under 0.x semver (breaking bumps the minor while the major is 0). It contains exactly ONE change, and it rejects request bodies 0.2.0 accepted.

Newly rejected — an unknown or misplaced key ANYWHERE in a quote request. QuoteRequestSchema and every object nested inside it are now strictObject: the root, source, destination, each entry of destination.calls[], and options. In 0.2.0 any key the schema did not declare was silently STRIPPED and the request answered 200. It is now a 400 whose unrecognized_keys issue names the offending key and the path of the object it appeared in.

The break you are most likely to hit: a field you have been sending at the wrong nesting level. It has been silently ignored the whole time, so nothing looked wrong, and on upgrade it starts returning 400. The known shapes of that mistake:

| Sent as | Belongs at | What 0.2.0 did | | --------------------------- | -------------------------- | ---------------------------------------------------- | | source.funder | funder | dropped it; the request had no funder | | destination.slippage | slippage | dropped it; priced at the default tolerance | | visibility | options.visibility | dropped it; a route you wanted private was published | | options.dappId | dappId | dropped it; you lost attribution | | options.allowHighSlippage | nowhere (deleted in 0.2.0) | dropped it silently |

source.funder is not a hypothetical: spelling funder there is how a funder-vs-signer check on the money path became a permanent no-op once before, and how two guards were later found reporting protection they did not provide — they read the nested path, the key was never there, and undefined === undefined passes for every input. A strict root turns that into a named 400 at the edge instead.

Before you upgrade, log the request bodies your client actually sends and diff their key sets against the schema. A field the server was ignoring is a field your integration was already not getting; the 400 is the first time you find out.

Two behaviors worth knowing:

  • An unrecognized key ABORTS the parse, so the cross-field diagnostics (the D7 destination.calls rules, the exact-out amount rules) do NOT also report on the same response. Fix the key, re-send, and the rest appears. Pinned in a test.
  • RESPONSE schemas are untouched and stay looseObject at every level. Forward compatibility runs the other way: a consumer must keep parsing fields a newer server adds. Also untouched: the four submit BODY schemas and their signed value objects (Permit3Payload relies on stripping a misplaced permit3.signature, so a reader has exactly one place to find the signature), and the three querystring schemas (proxies and clients add query params, a different risk profile from a JSON body).

The error catalog gained NO new codes — invalid-parameter and invalid-request already cover this. V1_SCHEMA_DIGEST changes, as it must: the published openapi/v1.json gains additionalProperties: false on all five request objects.

Migrating 0.1.0 -> 0.2.0

0.2.0 is a BREAKING release under 0.x semver (breaking bumps the minor while the major is 0). Three tightenings reject request bodies 0.1.0 accepted, and one packaging change alters how zod is resolved. Nothing here is a silent behavior change — every item below turns something that used to succeed into a named 400 or a failed install.

Install-time — zod is now a peerDependency (^4.3.0), not a dependency. A consumer that relied on the transitive copy must now declare zod itself. This is the fix for the two-zod-instance failure: the old exact 4.4.3 pin materialised a second nested zod for any consumer not on that exact version, producing TS2345 on _zod.version.minor and tsc OOMing at 4 GB with no message.

Newly rejected — an amount at or above 2^256. Every field typed UintString (quote source.amount, destination.amount, fee/step amounts, permit.value, permit.nonce, erc-3009 value/validAfter/validBefore, gasLimit, ...) is now bounded at 2^256-1. 0.1.0 validated a 200-digit value cleanly and let it revert or truncate on-chain. The published OpenAPI carries the coarse maxLength: 78; the exact ceiling is enforced on every parse.

Newly rejected — an unknown key on POST /v1/deposit-addresses. All three kind variants of DepositAddressCreateRequestSchema are strictObject. In 0.1.0 a misspelled field, or a field belonging to a different kind (destinationChainId on the same-chain gateway variant), was silently STRIPPED and answered with a 201 — a funds-receiving address created with semantics the caller did not request. It is now a 400 naming the offending key. Read paths and the lookup query are unchanged.

Newly rejected — a redirected permit.spender on POST /v1/deposit-addresses/submit/erc-2612. permit.spender must equal target.depositAddress, the same binding all three erc-3009 variants already enforced on authorization.to. A redirected spender was previously accepted here and then silently RE-ADDRESSED by the permit-transfer path.

Changed error rendering (not a rejection). Both binding refinements now carry the deposit-address-mismatch catalog code as issue data, so a consumer rendering through the new problemFromZodError gets that code and its 400 instead of the generic invalid-request — on the erc-3009 lane that is a code CHANGE for an already-rejected body. The code's catalog title generalizes to "The signed counterparty does not equal target.depositAddress" (it named erc-3009 only); code, status and legacy refs are untouched.

No validation change, but worth knowing. AnyAddress now tries TvmAddress before SvmAddress, so the anyOf branch ORDER in the published document differs. Every branch is a plain string, so accepted values and parsed output are identical — and AnyAddress must never be used to infer a VM either way (see its docblock). V1_SCHEMA_DIGEST changes, as it must: it is the artifact identity, not the version string.

New, additive: the zod-free ./v1/signature subpath; MAX_UINT256; and problemFromZodError / v1ErrorCodeFromIssues / v1IssueParams / V1_ISSUE_CODE_PARAM.

Consumers

| Consumer | Import | Zod requirement | | ------------------------------------ | ---------------------------------------------------------------------------------------- | -------------------------------------- | | eco-router (serves /v1) | import { v1 } from '@eco-foundation/api-schemas' | own zod satisfying the peer range | | deposit-addresses (native endpoints) | same, plus assertMatchesV1Schema from ./testing in CI | own zod satisfying the peer range | | solver-v2 (Zod 3 — must not upgrade) | import type { V1QuoteResponse } from '@eco-foundation/api-schemas/v1/types' + fixtures | ZERO zod at runtime and in the .d.ts | | any consumer needing signatureHash | import { signatureHash } from '@eco-foundation/api-schemas/v1/signature' | NONE — that subpath is zod-free |

Do you need zod? Only if you import a zod-bearing entry point. Since 0.3.1 the peer is declared optional (peerDependenciesMeta: { zod: { optional: true } }), because for two of the published entry points it is genuinely not needed:

  • Needs zod (^4.3.0): the bare package name ., ./testing, and anything reached through the v1 namespace on either. These load schema modules, and their .d.ts names zod types.
  • Needs NO zod: ./v1/signature (runtime code importing @noble/hashes and nothing else) and ./v1/types (plain type aliases, erased at compile time). A consumer whose imports stay inside these two installs only @noble/hashes — declaring zod would be dead weight.

Read optional narrowly: it says zod may be absent, not that any zod version works. pnpm suppresses the unmet-peer warning only when zod is MISSING (verified against pnpm 10.16); a consumer that HAS a zod outside ^4.3.0 — solver-v2, on zod 3 — still sees unmet peer zod@^4.3.0: found <version>, since that is a version mismatch rather than a missing peer. Suppressing that one is the consumer's own call (pnpm.peerDependencyRules.allowedVersions), and it is safe exactly while its imports are limited to the two zod-free subpaths above. The range itself cannot be widened — the schemas require Zod 4.

Zod-major compatibility is the reason there are two interfaces, not one:

  • Zod-4 consumers (eco-router ^4.4.3, deposit-addresses ^4.3.5) import the schemas directly. Since 0.2.0, zod is a peerDependency with a range (^4.3.0) — optional since 0.3.1 — not a hard-pinned dependency: the consumer's own single copy satisfies it. For these consumers optional changes nothing; they must still install zod. The previous exact dependencies pin at 4.4.3 materialised a SECOND nested zod for every consumer not on that exact version, and two zod instances in one type graph is the documented failure — TS2345 on _zod.version.minor when a package schema is passed to a local z.ZodType<T>, and tsc OOMing at 4 GB with no message. A consumer therefore installs zod itself (any 4.3.x+ 4.x); the package keeps an exact 4.4.3 devDependency so its own build and its generated OpenAPI stay byte-stable.
  • solver-v2 is on Zod 3 and must never be forced to Zod 4. It consumes the zod-free ./v1/types subpath (plain type aliases, no zod in the emitted .d.ts) plus the golden fixtures. Since 0.2.0 it can ALSO import ./v1/signature — runtime code, zod-free — which is the subpath that could replace its vendored local canonicalizeSignature. Both zod-free subpaths carry a physical v1/<name>.{js,d.ts} forwarder, because TypeScript under moduleResolution: node10 (the default for module: commonjs, which is what solver-v2 compiles with) IGNORES the exports map and the specifier otherwise fails with TS2307. Root-level type re-exports from the bare package name exist as a convenience for Zod-4 consumers, but their .d.ts reaches zod through the v1 namespace — solver-v2 must use the subpath.
  • viem is a dev-only dependency (the reference implementation the signature and quote-signature vector suites check against). It stays out of dependencies, so a consumer install pulls only zod and @noble/hashes.

Import surface

| Subpath | Contents | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | . | v1 namespace, V1_SCHEMA_DIGEST, assertMatchesV1Schema, loadV1Fixtures, generateOpenApiDocument, root type re-exports | | ./v1/types | zod-free type aliases (the solver-v2 interface) | | ./v1/signature | signatureHash + canonicalizeSignature alone — zod-free, loads no schema module | | ./testing | assertMatchesV1Schema, loadV1Fixtures, V1Fixture | | ./openapi/v1.json | the generated OpenAPI 3.1 document | | ./fixtures/signature-hash/vectors.json | cross-repo signatureHash vectors | | ./fixtures/quote-signature/vector-*.json | cross-repo F3 quote-signature (EIP-712) vectors |

Fixtures

There are two fixture locations and they serve different jobs — do not conflate them:

  • src/fixtures/v1/ — the 19 golden request/response payloads, one per endpoint variant, consumed through loadV1Fixtures() rather than by file path. Each entry carries its endpoint id, direction, variant, and the C12 replay metadata (method, path, expectedStatus, sideEffect, optional stability and headers), so a live-replay harness drives itself from the array. They are TypeScript-imported, so tsc emits them into dist/ and they ship compiled — no copy step.
  • Package-root fixtures/ — the shared cross-repo vectors, shipped raw and read by subpath, because all four repos assert against the same bytes: fixtures/signature-hash/vectors.json (the signatureHash canonicalization contract) and fixtures/quote-signature/vector-*.json (the F3 EIP-712 EcoQuoteV1 vectors, each with its digest, signature, and signer; the signing key is derived from a public domain string, so this repo ships no private-key bytes).

routes.list is the one fixture tagged stability: 'draft'. A live-replay consumer skips it with the reason draft endpoint (x-stability: draft) — not served at launch instead of demanding a 200 from an endpoint no service serves at launch, which makes the read-safe replay set 7 of the 8 GET fixtures. Schema conformance still covers it in this package's own suite, which needs no server.

Invariants (enforced in CI)

  • Error catalog is append-only: errors/catalog.lock.json diffed against origin/main (pnpm run catalog:check). Codes are explicit literals; legacyCodes are namespaced (eco-quotes: / deposit-addresses: collide numerically by construction).
  • openapi/v1.json is regenerated and diffed (pnpm run openapi:check).
  • Every fixture in src/fixtures/v1 parses through its endpoint schema, and its replay metadata matches the registry entry (method, expectedStatus, sideEffect, stability).
  • v1/types stays mutually assignable with the schemas (compile-time gate) and zod-free at runtime.
  • Wire schemas are transform-free; requests strip unknown keys, responses are loose (z.looseObject). Tolerant enum helpers exist for consumers only.
  • signatureHash = keccak256 of the canonical signature encoding (low-s, v ∈ {27,28}, 65-byte expanded). Consumers MUST enforce uniqueness in the store (unique index / atomic upsert) — never check-then-act.
  • fixtures/signature-hash/vectors.json is the cross-repo signatureHash contract: eco-router and deposit-addresses import the util and assert these vectors; solver-v2 keeps a local copy of the util ONLY with this file copied into its fixtures and asserted byte-for-byte. Those copies are held in sync by scripts/check-vendored-signature-vectors.sh at the repo root (pnpm run canon:check): it diffs every vendored copy against this file where the sibling checkout is present, and pins this file's sha256 so a vector revision fails in the same PR that makes it — a consuming repo cannot notice on its own, because each one asserts only its own copy.
  • fixtures/quote-signature/vector-*.json are the F3 quote-signature golden vectors (EIP-712 EcoQuoteV1 domain); eco-router's signing path must reproduce every digest/signature.
  • V1_SCHEMA_DIGEST is sha256 over errors/catalog.lock.json then openapi/v1.json, byte-exact and in that order, regenerated and diffed (pnpm run digest:check). It is the cross-consumer skew identity: every service serving or validating /v1 must report the same digest.
  • Status vocabularies are the single source (canon C1) — every service maps its internal states INTO them; the intentHashes: Bytes32[] handoff field is plural bare hashes everywhere.
  • A quote response's quotes field is ALWAYS present (P8): null unless the request set options.allQuotes, otherwise the ranked list (best-priced first) whose entry 0 IS the envelope's quote. Entries are the same core quote object, each independently executable and separately signed.

Gates

pnpm run verify is the whole gate and runs all six steps in order:

build → lint:check → test → catalog:check → openapi:check → digest:check

Both .github/workflows/ci.yml (every PR) and .github/workflows/publish.yml (every release tag) run exactly that one command.

The verify-before-stage step in the release workflow is load-bearing — do not streamline it away. build uses tsconfig.build.json, which excludes src/**/*.spec.ts, so the compile-time gates that live in spec files — most importantly the v1/types parity gate — cannot fail build. They fail test. Publishing without running verify would therefore ship a ./v1/types alias that has silently drifted from its schema.

What each gate says when it trips:

| Gate | Failure output | Fix | | --------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | types parity | error TS2344: Type '"V1QuoteRequest"' does not satisfy the constraint 'never' — the message NAMES the drifted alias | update the alias in src/v1/types.ts to match the schema | | openapi:check | openapi/v1.json is stale — run: pnpm run openapi:generate | pnpm run openapi:generate (byte-stable across runs) | | digest:check | src/generated/build-digest.json is stale — run: pnpm run digest:generate | pnpm run digest:generate | | catalog:check | a per-violation list naming the removed or mutated code | append instead of mutating; pnpm run catalog:lock |

Two catalog:check outcomes are NOT failures and must not be confused with each other:

  • no baseline lock at origin/main:… — append-only check skipped (first introduction) is correct while the lock has never been on main. The gate starts enforcing the moment it is.
  • An unresolvable origin/main FAILS instead, naming fetch-depth: 0 — a shallow clone must never silently turn the append-only gate into a permanent no-op.

Publishing

Publishing is two steps, and CI only does the first one.

1. Tag v<version> (must equal package.json version). The Stage release workflow verifies and then runs npm stage publish, which uploads the tarball to npm in a pending state. It fails with ::error::tag version <x> != package version <y> if the two drift, so a stale tag can never stage a mismatched artifact. It needs the NPM_TOKEN repo secret: a granular token scoped to @eco-foundation with "Read and write (stage only)" rights.

A green run means the version is QUEUED, not released. Nobody can install it yet.

2. A maintainer promotes it, which costs a 2FA challenge:

npm stage list @eco-foundation/api-schemas
npm stage approve <stage-id>          # or: npm stage reject <stage-id>

The queue is also visible on npmjs.com, and the workflow's run summary repeats these commands.

Why the split: npm removes direct publish from granular access tokens in January 2027, and an approval cannot be satisfied by any CI credential — token or OIDC — by design. A stolen runner token can queue a release; it can never ship one. npm stage approve needs npm ≥ 11.15.0 locally, same as CI.

Two staging rules worth knowing before you tag. The dist-tag is immutable once staged — it cannot be changed on the way to approval, only rejected and re-staged — and a version that is already staged cannot be staged again, so a re-tag of a queued version fails until the queued one is approved or rejected.

Semver: breaking schema change = major, additive = minor; consumers pin minor. While the package is 0.x, "pin minor" means ~0.7.0 or the exact 0.7.0 — never ^0 (see Install). Consumers additionally compare V1_SCHEMA_DIGEST: a matching version string is not evidence of a matching artifact. /v1/routes is draft-tagged and exempt until its stability flips.