Best-execution routing
The deterministic best-execution router: objective, constraints, and the off-chain/on-chain validation mirror.
Source: mirrored verbatim from
docs/routing.md. Generated at build time — edit the source file, not this page.
Matura's core differentiator. Given a wallet's targetAdvance and a list of its
claim ids, the router deterministically selects the cheapest verifiable route — a
set of (claimId, vault, faceAmount) legs — that the on-chain
MaturaRouter.executeRoute will independently accept.
- Pure optimizer:
packages/shared/src/routing/{optimize,arithmetic,optimize-io,reasons,candidate,explanation}.ts - API pipeline:
apps/api/src/routes/{routes.service,quote-collector,route-hash,route-intent.service,routes.dto}.ts - On-chain reference:
packages/contracts/contracts/MaturaRouter.sol(_validateLegs) andlibraries/MaturaPricing.sol(the pricing formula).
The optimizer is a pure bigint-only function; the API pipeline collects pinned
chain state, runs the optimizer, persists a short-lived intent, and later re-validates
against a fresh block before emitting EIP-712 typed data to sign.
1. Objective and constraints
Objective: meet targetAdvance at minimum total cost (equivalently, minimum
assigned face, since cost is a monotone function of face at a fixed rate). The
comparator isBetter (optimize.ts) ranks feasible allocations by, in order:
- lower
totalCost; - fewer legs;
- lower
totalFace; - stable lexicographic
(claimId, vault)leg key.
This chain is deterministic: identical inputs at the same pinned block yield a byte-identical route.
Constraints (enforced across the collector, the optimizer, and the on-chain contract):
- Remaining face — a leg draws at most
remainingFace = faceValue − financedFaceValue. - Per-vault liquidity aggregation — advances are summed per distinct vault and
must not exceed that vault's
fundableLiquidity()snapshot (mirrorsMaturaRouter._validateLegs, which accumulatesreserved[vi]and reverts onreserved[vi] > fundable[vi]). Liquidity is denominated in advance/settlement units, so face caps are derived from it via the pricing curve. - Supported claim-type bitmap —
(mandate.supportedTypesBitmap >> claimType) & 1. - Issuer eligibility —
IssuerRegistry.isActive(issuer)(global), re-checked per leg. - Vault active —
VaultRegistry.isActive(vault)(getVaults()returns all vaults; the collector filters to active-only). - Token match —
vault.token() == claim.token. maxDurationDays—(dueDate − blockTimestamp) / 86400 ≤ mandate.maxDurationDays; a past-due claim is rejected outright.- Lot bounds — face ≥
mandate.minFace(min lot) and ≤min(mandate.maxFace, remainingFace). MAX_LEGS = 8— at most 8 legs per route (MaturaConstants.MAX_LEGS,MAX_ROUTE_LEGSinoptimize-io.ts).- One claim → one vault — a claim appears at most once per route
(contract-enforced via
_requireNoDuplicateClaims; no intra-route split). - Slice count —
slicesRemaining = MAX_SLICES_PER_CLAIM (8) − sliceCount > 0. - Integer base-unit arithmetic — every money value is a
bigint/base-unit string; no floats, noMath.floor, no1e4.advance/discountround exactly as Solidity does. - Determinism — no
Date/Math.random/network/DB in the optimizer; a fixed canonical candidate ordering feeds both selection and hashing.
2. The size-independent (linear) pricing assumption
The optimizer treats each (claim, vault) candidate as having a size-independent
discount rate rateBps, recovered once from a probe quote at maxFace:
rateBps = ceil(discountAmount * 10_000 / maxFace) // quote-collector.ts, ceilRateBpsSelection then computes advance directly from face via the one reimplemented formula
(arithmetic.ts), which mirrors MaturaPricing exactly (discount rounds up,
advance rounds down):
advance(face, bps) = face − ceilDiv(face * bps, 10_000)This is an assumption, not a theorem: it holds because on-chain totalBps
(baseDiscountBps + durationBpsPerDay * daysToDue + premiumBps) depends only on the
mandate, claim type, and maturity — not on face. It is pinned to reality by
golden-vector tests that assert the reimplemented advance(face, bps) matches
quoteAndCheck across multiple face sizes on one (claim, vault) (a rounding
non-linearity at the margin would only surface in a vector, since the property test
shares the same assumption).
Favorable drift. daysToDue is non-increasing as block.timestamp advances, so
totalBps can only fall and advance can only rise between the optimize block and
the (later) prepare/execution block — until the claim goes past due, when the quote
fails. Because delay only increases advance, the optimizer's advance is a conservative
lower bound, and the authoritative re-quote at prepare meets or exceeds it. (At a
day-rollover the advance jumps up; equality holds only at the same block.)
3. Algorithm
optimizeRoute(input) (optimize.ts) dispatches on the number of distinct claims:
-
≤
EXACT_SEARCH_MAX_CLAIMS(= 12): bounded exact search — optimal.exactSearchrecurses over claims; for each claim it either skips it or chooses exactly one of its eligible vaults, pruning any branch with more thanmaxLegschosen. For each chosen subset it runsfillByRate(greedy min-cost face allocation: cheapest rate first, honoring per-vault liquidity aggregation, min-lot smallest-unavoidable overshoot, andmaxFace), keeps only allocations that meettarget(and the caps), and retains the best underisBetter. Enumerating the vault choice per claim is what a pure cheapest-vault-per-claim greedy would miss (the rate-vs-capacity trade-off); vault assignment within a subset stays greedy (neverV^k).Complexity is bounded by the branching
(1 + vaultsPerClaim)^claims, capped by themaxLegs ≤ 8prune — not2^nover all subsets. At the MVP scale (≤ 12 distinct claims, a handful of vaults each) the worst case is a few milliseconds of synchronousbigintwork. This is a synchronous, event-loop-blocking search; the= 12bound keeps that block short, and the DTO layer independently caps request size before any chain read (that is the DoS control — see §8). -
> 12 distinct claims: cheapest-vault-per-claim greedy — approximate. Each claim is reduced to its cheapest vault;
fillByRateallocates across them. If it meets target within the caps the route is returned, otherwiseTARGET_UNSATISFIABLE.
approximation disclosure. RouteResult.approximation is "exact" for the bounded
search and "greedy" for the degraded fallback, so a client never sees a possibly
sub-optimal route under the "cheapest route" headline. (The explanation.strategy
field carries the finer label: "bounded-exact" or "greedy-degraded".)
Infeasible inputs return a structured NonExecutableResult (never a bogus route):
NO_ELIGIBLE_CANDIDATES (empty input), MAX_COST_EXCEEDED (target reachable but caps
block it — distinguished by re-running the search with caps ignored), or
TARGET_UNSATISFIABLE. It carries shortfallAdvance, maxAchievableAdvance (from a
capacity-maximizing greedy), and a bestFeasiblePartial when one exists.
Because this is bounded and appropriate only at MVP scale, growth beyond a small domain would require replacing the exact search (e.g. an ILP / min-cost-flow model) rather than raising the cap.
4. Two-phase pipeline
Phase 1 — POST /api/v1/routes/optimize
RoutesService.optimize (wallet from the SIWE JWT, never the body):
- Pin one finalized block:
chain.getFrontierBlock()→contracts.pinnedAt(number)returns aPinnedReadsfacade so every read in the request shares one block (structurally impossible to read atlatest). - Short-circuit if
routerPaused()(fail-closed → all claims filtered asROUTER_PAUSED). collectCandidatesfans out concurrent read waves (vaults, then per-claim / per-vault quotes) all pinned to that block, filtering tobeneficiary == walletbefore quoting and mapping every hard-constraint failure to a machine reason.- Run the pure
optimizeRoute. - If executable: compute
quoteSnapshotHash+routeId, then persist aRouteIntent(createIfAbsent) with a 120s TTL, and return{ routeId, expiresAt, result, filteredOut, blockNumber, finalizedThrough }.
The returned advance/cost/effectiveDiscountBps are estimate-at-block, tied to
blockNumber/finalizedThrough in the response. They are advisory; the authoritative
gate is the re-quote at prepare.
Phase 2 — POST /api/v1/routes/:routeId/prepare-execution
RoutesService.prepareExecution:
- Atomic single-use consume (
intents.consume): a guardedupdateMany … WHERE status='PENDING' AND expiresAt > now()flips the row toCONSUMED; exactly one concurrent caller wins. A 0-row result is classified via a user-scoped lookup (never a cross-user status oracle). - Re-pin a fresh finalized block and re-check
routerPaused(). - Authoritative re-quote + re-mirror (
revalidateLegs): each stored leg is re-validated on-chain at its exact chosenfaceAmount, andminimumAdvanceAmountis set to that fresh re-quoted advance. Per-vault advances are re-aggregated against a freshfundableLiquidity(). ThentotalAdvance ≥ targetAdvanceandtotalFace ≤ maxTotalFaceare re-checked. - Bind the router nonce (
routerNonce(user)) and a bounded deadlinemin(userDeadline, now + 300s), refusing if it is within a 30s margin of now. - Emit the
ExecutionRouteEIP-712 typed data (buildExecutionRouteTypedData); theexecutionIdishashTypedDataof the bigint message.
The re-quote/re-mirror at prepare is the authoritative gate. The optimizer's
feasibility logic is advisory: any optimizer bug surfaces as a spurious
NonExecutableResult or a prepare-time 422 (a false negative), never as a route that
reverts on an _validateLegs-checkable condition. On any re-validation failure the
consumed intent is marked FAILED (fail-closed) and the error propagates.
5. Off-chain mirror scope
The mirror reproduces MaturaRouter._validateLegs (and route-level checks) off-chain.
It appears in two places: the collector (candidate qualification, with per-failure
reasons) and revalidateLegs (the authoritative prepare-time gate).
Mirrored _validateLegs checks: beneficiary == user · vault.token() == claim.token
· state ∈ {ELIGIBLE, PARTIALLY_FUNDED} · IssuerRegistry.isActive(issuer) ·
VaultRegistry.isActive(vault) · quoteAndCheck.ok · advance ≥ minimumAdvanceAmount
(at prepare, minimumAdvanceAmount is the re-quoted advance, so this is trivially
satisfied) · per-distinct-vault reserved advance ≤ cached fundableLiquidity() ·
no duplicate claimId · legs.length ≤ MAX_LEGS · totalFace ≤ maxTotalFace ·
totalAdvance ≥ targetAdvance.
Additions beyond _validateLegs: router paused() (checked first, fail-closed) and
per-claim slicesRemaining > 0 (reserveSlice reverts in the effects phase, so it is
un-mirrorable from _validateLegs alone).
Dedup, MAX_LEGS, and slice-count are enforced at collection/optimize time (the
optimizer only ever emits ≤ 8 legs, one per claim, from candidates that already passed
slice-count); the prepare-time mirror re-checks the per-leg on-chain predicate plus
per-vault aggregation and the route totals.
Documented out of mirror scope (surfaced as expected failures, not bugs):
- Effects-phase reverts inside
reserveSlice/registerAllocation/vault.fundbeyond the slice-count already checked — not reachable by aviewmirror. - Nonce bump between prepare and submit — if the user's router nonce advances after
prepare, the transaction fails with the expected
InvalidAccountNonce. The bounded deadline keeps this window short.
6. minimumAdvanceAmount is the only on-chain cost floor
ExecutionRoute has no cost field on-chain. The only per-leg economic guarantee the
contract enforces is advance ≥ minimumAdvanceAmount. At prepare, each leg's
minimumAdvanceAmount is set to its fresh re-quoted advance (revalidateLegs), so
the signed message pins the exact advance the user is promised. Combined with favorable
drift (§2), this is what makes the client-supplied maxTotalCost safe: it is
advisory — enforced only inside the optimizer (withinCaps), labeled as such in the
response, and never re-checked on-chain.
7. Determinism and hashing
- Content hash (
route-hash.ts):contentHash(v) = keccak256(stringToHex(canonical(v))).canonicalsorts object keys, serializes only JSON scalars, and throws on anybigint/undefined— so byte-identical determinism is enforced, not incidental (every money value must be pre-stringified). quoteSnapshotHash= hash of{ blockNumber, candidates }(candidates sorted by(claimId, vault)).routeId= hash of{ user, quoteSnapshotHash, legs (sorted), targetAdvance, maxTotalFace, maxTotalCost, routeDeadlineSeconds }. The router nonce is excluded — it is bound only at prepare — so re-optimizing identical inputs at the same block yields the samerouteId(idempotent optimize).RouteIntentsingle-use (route-intent.service.ts):- Create-or-ignore —
create, catching the P2002 unique-violation and returning the existing row. Never an upsert (an upsert on the content-hash PK could revive aCONSUMEDintent; a collision is byte-identical by construction, so returning the existing row is safe). - Atomic consume — the guarded
updateManydescribed in §4. - Opportunistic prune — a best-effort
deleteManyof expired-or-consumed rows at the top of every optimize (mirrorsAuthNonce; no cron), backed by@@index([expiresAt]). - 120s TTL (
ROUTE_INTENT_TTL_SECONDS); expiry is theexpiresAt > now()predicate, never a persisted status.
- Create-or-ignore —
8. HTTP error semantics
Coded errors (common/http-errors.ts) carry a stable machine code alongside the HTTP
status.
- Oversized input —
claimIdsis capped atMAX_CLAIM_IDS = 20in the DTO, so an oversized request is rejected before any chain read (DoS guard). This is distinct fromEXACT_SEARCH_MAX_CLAIMS, which only bounds the in-optimizer search. - Double-prepare / already consumed —
409 Conflict, codeROUTE_INTENT_UNAVAILABLE. - Not found / wrong owner —
404, codeROUTE_INTENT_NOT_FOUND(identical for not-found and wrong-user — no status oracle). - Expired intent —
422, codeROUTE_INTENT_EXPIRED(re-optimize). (There is no410 Gonehelper today; expiry uses the 422 envelope.) - Post-consume re-validation failed —
422with a specific code (ROUTER_PAUSED,LEG_NOT_FINANCEABLE,ISSUER_INACTIVE,VAULT_INACTIVE,TOKEN_MISMATCH,MANDATE_REJECTED,TARGET_NO_LONGER_MET,INSUFFICIENT_LIQUIDITY,ROUTE_DEADLINE_TOO_SOON); the intent is markedFAILED. The 422 signals "re-optimize", distinct from the 409 conflict. - Non-executable optimize — a
200withresult.executable = falseand a structured reason (not an HTTP error); no intent is persisted.
9. Follow-ups
- Golden-vector differential generator + CI freshness gate not yet implemented. The
optimizer is currently validated by a fast-check property test against an independent
brute-force reference (feasibility, optimality-by-value, deterministic tie-break) plus
an arithmetic parity unit test against the Solidity rounding. The full Hardhat
generator that dumps
quoteAndCheck/ accept-reject / aggregate vectors — and the regenerate-and-git difffreshness gate mirroring the ABI/manifest gates — is still outstanding. Until it lands, the multi-face linearity assumption (§2) is not yet pinned to on-chain reality by a committed vector. executions-preparemirror not yet unified. The existing/executions/prepareservice still lacks the token / issuer / vault-active checks that this module'srevalidateLegsmirror adds. Extracting one shared per-legevaluateLegpredicate used by the collector,revalidateLegs, andexecutions-prepareis a follow-up.