@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
Keywords
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 conventionerc-3009anderc-2612already 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 takespermits[0].chainId. It also carries noleavesand notokenKey— the solver recomputes the merkle tree from the legs, andtokenKeyispad(token, 32)(ERC-20 only) by definition. - permit3
permitContract,timestamp,merkleRootare the signer's values, never synthesized: the solver recomputes the root from the legs and rejects a mismatch. - permit3 leg
accountandmodeOrExpiration:accountis where the leg pushes funds (mode0, 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,timestampandmodeOrExpirationareUint48, a new primitive (withUINT48_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
sigDeadlineis a decimal STRING (UintString), anddetails.expirationaUint48— both carry their on-chain width instead ofUnixSeconds. The Permit2 SDK'sMaxAllowanceExpiration(2^48-1) andMaxSigDeadline(2^256-1) are the standard "no expiry" idiom many wallets sign, and a<=2100bound rejected a valid signature that would have funded. Same narrow exception erc-3009'svalidAfter/validBeforecarry; a NUMBERsigDeadlineis a 400 atpermit2.sigDeadline. Every other deadline in the module (permit3.deadline, erc-2612's) is stillUnixSecondsand still rejects a milliseconds value. Permit3PayloadSchemastays a plain strippingz.object. Apermit3.signaturesent 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.quoteis['erc-3009'].depositAddressis 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 fromV1_SUBMIT_SCHEMAS_BY_SERVICE.intentsanswersinvalid-parameteron 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_SCHEMASis keyed by the newLaneStandardtype ('erc-3009' | 'erc-2612'— the standards that have a lane), not bySubmitStandard.V1_SUBMIT_SCHEMAS[standard]with aSubmitStandard-typed index no longer compiles; narrow toLaneStandardfirst. That is the intended signal — the alternative was anundefineda dispatcher trips over at runtime.SUBMIT_STANDARDSandSubmitStandardKEEP all four spellings. That enum is also the discovery vocabulary a token advertises undersupports, and narrowing it would make a consumer hard-fail on a token list from a server still spellingpermit3. The vocabulary is what a token MAY advertise; the matrix is what has a lane. The tokens fixture advertisesquote: ['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
chainIdsays, 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 theAnyAddressdocblock), 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:
- Fetch a recent blockhash (or use a durable nonce).
- Compile
instructionsin the given order into a legacy or v0 message, withfeePayeras the fee payer. - Sign with every key marked
isSigner—feePayeris always one of them. - 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[].accountsis 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 PortalfundreportsInvalidVault,InvalidAtaorInvalidTokenTransferAccountsdepending on which pair swapped). The schema does not know which programprogramIdis, so it cannot police this; it validates each entry's shape and the PRESENCE of both privilege flags, and nothing more.instructionsmay exceed one transaction, and that is not an error. A public-visibility quote emitsPortal.publish(carrying the whole route preimage) plusPortal.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, sopublishalone is roughly 710 bytes of instruction data,fundadds 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.json —
z.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:
registrationis 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 notisDeployed, which is CREATE2 deployment of the address contract:isDeployed: falsedoes 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.callsrules, the exact-outamountrules) 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
looseObjectat 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 (Permit3Payloadrelies on stripping a misplacedpermit3.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 thev1namespace on either. These load schema modules, and their.d.tsnames zod types. - Needs NO
zod:./v1/signature(runtime code importing@noble/hashesand nothing else) and./v1/types(plaintypealiases, 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. Since0.2.0,zodis apeerDependencywith a range (^4.3.0) — optional since0.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 exactdependenciespin at4.4.3materialised a SECOND nested zod for every consumer not on that exact version, and two zod instances in one type graph is the documented failure —TS2345on_zod.version.minorwhen a package schema is passed to a localz.ZodType<T>, andtscOOMing at 4 GB with no message. A consumer therefore installs zod itself (any4.3.x+4.x); the package keeps an exact4.4.3devDependency 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/typessubpath (plaintypealiases, no zod in the emitted.d.ts) plus the golden fixtures. Since0.2.0it can ALSO import./v1/signature— runtime code, zod-free — which is the subpath that could replace its vendored localcanonicalizeSignature. Both zod-free subpaths carry a physicalv1/<name>.{js,d.ts}forwarder, because TypeScript undermoduleResolution: node10(the default formodule: commonjs, which is what solver-v2 compiles with) IGNORES theexportsmap 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.tsreaches zod through thev1namespace — solver-v2 must use the subpath. viemis a dev-only dependency (the reference implementation the signature and quote-signature vector suites check against). It stays out ofdependencies, so a consumer install pulls onlyzodand@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 throughloadV1Fixtures()rather than by file path. Each entry carries its endpoint id, direction, variant, and the C12 replay metadata (method,path,expectedStatus,sideEffect, optionalstabilityandheaders), so a live-replay harness drives itself from the array. They are TypeScript-imported, sotscemits them intodist/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(thesignatureHashcanonicalization contract) andfixtures/quote-signature/vector-*.json(the F3 EIP-712EcoQuoteV1vectors, each with itsdigest,signature, andsigner; 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.jsondiffed againstorigin/main(pnpm run catalog:check). Codes are explicit literals;legacyCodesare namespaced (eco-quotes:/deposit-addresses:collide numerically by construction). openapi/v1.jsonis regenerated and diffed (pnpm run openapi:check).- Every fixture in
src/fixtures/v1parses through its endpoint schema, and its replay metadata matches the registry entry (method,expectedStatus,sideEffect,stability). v1/typesstays 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.jsonis the cross-reposignatureHashcontract: 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 byscripts/check-vendored-signature-vectors.shat 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-*.jsonare the F3 quote-signature golden vectors (EIP-712EcoQuoteV1domain); eco-router's signing path must reproduce everydigest/signature.V1_SCHEMA_DIGESTis sha256 overerrors/catalog.lock.jsonthenopenapi/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/v1must 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
quotesfield is ALWAYS present (P8):nullunless the request setoptions.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:checkBoth .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 onmain. The gate starts enforcing the moment it is.- An unresolvable
origin/mainFAILS instead, namingfetch-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.
