Repository navigation
ci(release): version packages - #156
Open
github-actions[bot] wants to merge 1 commit into
Open
github-actions[bot] wants to merge 1 commit into
github-actions[bot] wants to merge 1 commit into
Conversation
github-actions
Bot
force-pushed
the
changeset-release/main
branch
15 times, most recently
from
September 8, 2026 11:42
8357a61 to
6720052
Compare
github-actions
Bot
force-pushed
the
changeset-release/main
branch
15 times, most recently
from
September 30, 2026 18:31
1c269ef to
0e23ec8
Compare
github-actions
Bot
force-pushed
the
changeset-release/main
branch
29 times, most recently
from
October 19, 2026 14:07
1dcf366 to
c7be0ee
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
lens@0.3.0
Minor Changes
Add pagination and a revoked filter to
GET /admin/keys.limit(default 50, max 100) andoffsetpage the listing,revoked=true|false(defaultfalse) selects live or revoked keys, and the response addspaginationmetadata with the total matching count. Invalid values return 400.ad9152e: Add a network-aware aggregated
/book/:assetA/:assetBendpoint with live SDEX levels, synthetic AMM reserve steps, cumulative depth, and source provenance.Add a single enumerated set of stable, machine-readable error codes. Every JSON
error response now carries a
codebeside its existingerrorstring:errorstays human-readable and may be reworded at any time, while
codeis a contracta client can branch on instead of matching on sentences.
The codes are declared once in
src/errors.ts, emitted through a sharedapiErrorhelper, and mirrored inopenapi.yaml. A guard test fails the buildif the spec and the enum drift apart, or if any route emits a code outside the
set — so a typo cannot ship as a new code.
Record when an API key was last used to authenticate (Nothing records when a key was last used #500).
api_keys.last_used_atis stamped on each successful authentication, throttled to one write per key perKEY_LAST_USED_THROTTLE_SECONDS(default 60) so authentication stays a read for busy keys, and surfaced onGET /admin/keys— null meaning the key has not authenticated since the column existed, which is what makes an abandoned key visible. A failed write never refuses a valid request, but is logged once per key rather than swallowed.Cache API-key lookups in-process so authentication is no longer a Postgres round-trip per request (Every authenticated request costs a database round-trip #495). Positive and negative lookups are held for
API_KEY_CACHE_TTLseconds (default 30), the cache is bounded at 10,000 entries so invalid keys cannot grow it without limit, hit/miss is exported asapi_key_cache_lookups_total, and a key's ownexpiresAtis re-checked on every hit so the TTL can never outlive the key. Revoking or rotating a key drops its cached entry immediately on the instance handling the request; another instance picks it up within the TTL. Rotation now also carries the old key's scopes to the replacement.c8e96b3: Serve every public route under a
/v1version segment, keeping theunversioned paths as deprecated aliases that answer identically.
src/api/versioning.tsstrips the segment in a FastifyrewriteUrlhook beforethe request is routed, so
/v1/price/XLM/USDCand/price/XLM/USDCresolve tothe same route definition — same handler, same auth, same x402 gating and rate
limits — rather than being mounted twice and drifting. Existing callers are not
broken: the unversioned form keeps working, and every response to it now carries
Deprecation: trueand aLink: </v1/...>; rel="successor-version"headerpointing at the versioned path. The versioned response carries neither.
openapi.yaml(and the generatedopenapi.json) now document the/v1paths;tests/openapi.test.tsasserts that every documented versioned path maps onto aregistered route, that the unversioned alias exists for each, and that the two
forms answer identically. Operator-only and non-HTTP routes (
/metrics,/ws,/admin/*,/graphiql*) are excluded and remain unversioned.The warning contract and the alias-retirement window — at least two minor
releases, no more than twelve months — are written down in
docs/api-versioning.md.36eb950: Automatic cataloging of resources from discovery extension upon settlement (Bazaar: automatic cataloging from the discovery extension #130)
81d8e59: Add seller-side Bazaar discovery helpers (
src/bazaar/declare.ts):param.*constructors that make per-parameter descriptions a required positional
argument,
declareHttpResource/declareMcpToolbuilders that assemble theextensions.bazaardeclaration and its JSON Schema, andvalidateDeclaration/assertDeclarationwhich run the catalog's ownvalidateListingplus seller-side metadata-quality rules so malformed listingsfail at development time rather than in production. Lens's own gated routes
(
/price,/candles,/pools,/price/twap, and the MCP price tool) aredeclared with them in
src/bazaar/lensListings.ts.4dc40a3: Ingest Blend lending pool state (Blend lending markets have no price or rate ingestion #212). A dedicated ingester reads pool reserves over Soroban, derives utilisation and supply/borrow APR, and stores them in
lending_rates— notprice_points, because a lending rate is not a spot price.GET /rates/:assetserves them, a retention job prunes old rows, and the cycle is bounded by the same per-ingester deadline every other loop uses.a1c5637: Ship the first real centralized-exchange adapter in
src/ingest/cex/(issuesrc/ingest/cex/ships only a mock #215). The directory previously held only theCexAdapterinterface and a mock, so every cross-venue comparison compared Stellar venues against nothing.CoinbaseAdapterimplementsCexAdapterin full —fetchTickerandfetchOrderBookagainst Coinbase Exchange's public market-data endpoints — and is built and registered through a new exchange-agnosticcreateCexAdapter(name)factory (registerCexAdapterFactorywires concrete exchanges in).It normalises pairs in both directions, mapping the Lens canonical form (
XLM/USD) to the identifier the exchange actually uses (XLM-USD) and back, since a slash-form request 404s. Requests are throttled to Coinbase's published public limit (10 req/s per IP, burst up to 15) with a token bucket; a429honoursRetry-Afterwhen present and otherwise backs off exponentially. Any outage, timeout, non-2xx, or malformed body degrades to anullreturn — the adapter never throws, so a dead exchange cannot take down the process or surface an error response. Prices carry a distinctsource(coinbase_cex) and take thenetworkfrom the caller rather than hard-coding it, so a CEX number is never mistaken for, or silently blended with, an on-chain one.98c8dff: Serialise decimal quantities as exact strings across the API boundary via header/query/Accept negotiation to preserve 18-decimal precision without IEEE-754 floating-point loss.
0dfa3ba: DeFindex vault shares are now priced. A new
DeFindexvenue adapter(
src/ingest/venues/defindex.ts) enumerates every vault from the protocol'son-chain factory registry, reads each vault's total managed assets and total
share supply straight from the chain, and derives the share price as
totalAssets / totalShares— never from a published figure that could be stale.A vault with zero shares yields no point rather than dividing by zero. Rows are
written with
source = 'defindex_vault'so a derived vault price is neversilently blended with a traded one, and each row carries the network it was read
from, so the ingester runs once per
ENABLED_NETWORKSentry without mergingmainnet and testnet series. The factory contract ids are taken whole from DeFindex's
published
mainnet.contracts.json/testnet.contracts.json.33c6308: Add GET /discovery/search with relevance ranking and quality evaluation (Bazaar: GET /discovery/search with real ranking and a quality evaluation #129)
373f389: Add GET /price/:asset/fiat for local currency conversion and naira valuation (No route answers what an asset is worth in naira #290)
059ca5a: Add a
priceUpdated(pair: String!, network: String)GraphQL subscription that streams live prices over the existing/graphqlendpoint (graphql-transport-ws protocol). Every ingester (SDEX, Horizon AMM, Soroswap) publishes{ pair, price, ts, network }on each new price; subscribers receive only the pair they request.networkis optional and narrows the stream to one chain — omitting it delivers every enabled network, which is only safe if the subscriber reads thenetworkfield on each message, since a dual-network deployment otherwise interleaves two chains' prices for the same pair.1e7da6d: Add a
screenerGraphQL query (There is no screener query #367). REST and GraphQL now share one implementation insrc/services/screener.ts, so filters, sorting, the keyset cursor and the[1, 100]limit clamp cannot drift between the two surfaces.sortByandorderare allow-listed at the SQL boundary, and every CTE is scoped to a single network.e37c85e: Breaking: range endpoints now use half-open intervals —
[from, to), not[from, to].fromis inclusive,tois exclusive.GET /candles/:assetA/:assetBandGET /prices/historypreviously returnedsamples with
timestampinfrom..toinclusive at both ends. A caller pagingback-to-back windows (
[T0, T1], then[T1, T2]) got every sample landingexactly on
T1twice — once in each response — so the two windowstogether double-counted the boundary and inflated any total, average or VWAP
computed over them.
tois now exclusive. Adjacent windows[T0, T1)and[T1, T2)share noboundary, and a sample landing exactly on
T1appears in exactly one of thetwo responses.
Callers that relied on the inclusive end must widen their
toby one tick(the smallest interval they query) to keep the same rows — e.g.
[T0, T1]isnow
[T0, T1 + 1s)atinterval=1m.Every
price_pointsquery now also carries thenetworkpredicate, so the(network, pair_key, timestamp)index is usable and a pair's price is never ablend of two chains.
GET /candles/:assetA/:assetBandGET /basketwerereading both networks;
GET /benchmark/:assetwas computing its deviationfigures from both. They now default to the active network and honour
?network=/x-network.39f565f: Add
/liveand/ready, so a platform can tell "kill this one" apart from"stop sending traffic here".
/statusanswered neither question. It readsindexer_state, so it fails whenPostgres is unreachable — useless as a liveness signal — and it says nothing
about whether the process has finished booting.
app.listen()runs part-waythrough
main(), before the ingesters, the retention worker and the websocketfan-out start, so between listening and those loops running the process accepts
requests it cannot yet answer.
GET /livetouches no dependency and stays healthy while Postgres is down orthe app is still booting. That is the point: a liveness probe that fails during
a database outage has the orchestrator restarting every instance at once.
GET /readyreports503withok: falseuntilmain()finishes booting, andwhile Postgres is unreachable or unmigrated, or Redis is unreachable when Redis
is part of readiness (
READINESS_REDIS_CHECK, default true). The Postgres checkis one
to_regclassquery answering both reachability and whether migrationswere applied — reachability alone would report a process with no tables as
ready. Probes are bounded by
READINESS_TIMEOUT_MSand answers cached forREADINESS_CACHE_MS, so polling every few seconds costs at most one round tripper dependency per interval.
Both are public, allow-listed from rate limiting next to
/status, sentCache-Control: no-store, and excluded fromhttp_requests_total— afixed-interval poller would otherwise put a floor under the request rate that no
amount of real traffic dropping could go below.
/statusis unchanged.The deployed smoke check now requires both probes instead of skipping
/readywhen it is absent, and fails on a failing
/live.5306598: Bound every ingester cycle with a deadline so one slow upstream can no longer stall an ingester while every health signal still reports it as healthy. Each round of a venue loop (SDEX, AMM, Soroswap, Aquarius) or the snapshot appender now runs under
runCycleWithDeadline: a cycle over its deadline is aborted at its next unit checkpoint, counted in the newingester_abandoned_cycles_totalmetric (venue,networklabels) and logged, and the following cycle starts only after the abandoned one has unwound — two cycles against the same venue can never overlap, and because every checkpoint sits before a write unit rather than inside one, an abandoned cycle can never leave a partial write. The per-ingester deadline is configurable viaINGEST_CYCLE_DEADLINE_MSwith<VENUE>/<NETWORK>overrides (0 disables it), venue adapters receive the cycleAbortSignalas an optional thirdingestPairargument, andstartVenueAdapteraccepts an optionalstopSignalso a loop can be stopped cleanly at a cycle boundary.ef57dd4: Publish market coverage ratio overall and per asset (
GET /coverageandGET /coverage/:asset) comparing Lens indexed trade volume and count against total Stellar network activity from Horizon over a time window. Document reproducible methodology indocs/coverage-methodology.md, report unflattering 0% coverage faithfully, and returncoverageRatio: nullwithstatus: "unanswerable"when network activity is zero.0ce55ff: The MCP server can now serve Streamable HTTP, selected with
MCP_TRANSPORT=http.Until then stdio was the only transport, so the server could only be used by a
client able to launch this process on the same machine — which rules out every
hosted agent, and means the agent's secret key has to be installed on each
machine that wants to pay.
HTTP reaches the network, so it is only allowed to start authenticated.
resolveMcpTransportConfig(src/mcp/config.ts) requires a non-emptyMCP_HTTP_AUTH_TOKENbefore anything else happens, andrunHttp()throws againif handed a blank one, so an unauthenticated remote server with
paid_callinits tool list cannot be brought up by configuration or by an embedding caller.
The example entrypoint resolves the transport before it builds a signer: a
server that cannot authenticate callers never reaches the point where it can
spend. Every request must then present
Authorization: Bearer <token>, comparedover SHA-256 digests with
timingSafeEqual; a missing, malformed or wrong tokenis refused with
401,www-authenticate: Bearer realm="lens-mcp"and the newERR_UNAUTHORIZEDcode, before the body is read and before any tool runs. Thetoken is never echoed back into an error message or a log line.
It binds
127.0.0.1unlessMCP_HTTP_HOSTsays otherwise, answers MCP only onMCP_HTTP_PATH(default/mcp), rejects anything else with404withoutrevealing whether the path exists, and requires
POST—GETgets405, whichspec-compliant clients read as "no server-initiated stream" rather than an
error. Bodies are capped at 1 MiB and must be
application/json, so a callercannot make the payment path stream an unbounded request.
Both transports register the same tools from the same code path, and share
one spend budget and payment ledger per process, so a remote client cannot spend
past a ceiling a local one already used. State is deliberately not shared
across requests: the SDK's stateless Streamable HTTP transport is single-use — a
second request on one instance throws, because message ids from different
clients would collide — so each
POSTgets its own protocol server andtransport.
stdio remains the default: with
MCP_TRANSPORTunset, or set tostdio, theserver talks to stdin and stdout exactly as before. An unrecognised value throws
instead of quietly falling back, because a server that silently ignored the
transport it was told to serve would be hard to trust with a key.
3b86612: Add the
lens_get_priceMCP tool, so an agent connected over MCP can ask Lens foran aggregated price instead of only searching and paying other people's Bazaar
nodes. Assets are
CODEorCODE:ISSUER, the issuer binds when both sides nameone (a same-named asset is refused with
ERR_ASSET_ISSUER_MISMATCH, neverpriced), the network defaults to the one the server runs, and the answer is
structured content carrying the price, venues, source count, confidence, the age
of the last trade and the best route.
c753139: Add the
lens_list_pairs,lens_get_poolsandlens_screen_pairsMCP tools(No tool exposes pairs, pools or the screener #409), so an agent that can already ask for a price can also ask what is
quotable, what liquidity stands behind it and what is moving — the three
questions REST answered and MCP did not, which left an agent guessing asset
codes. Each tool runs the function its REST route runs (
listPairs,listPoolsand the screener service behind
GET /screenerand the GraphQL query), so apage over MCP is the same rows, filters and cursor as a page over HTTP. Pages
are bounded by the server's configured page limit and carry
nextCursorandhasMore; a page too large for the tool output limit is returned labelled astruncated, without a cursor that would skip the rows it dropped. Every asset
carries its issuer in full, since a Stellar asset code alone identifies nothing,
and each description says which of the three questions it answers.
94bd8ea: Gate
paid_callbehind an operator allow-list of hosts (paid_callwill pay whatever URL the agent names #404). The tool payswhatever URL it is given, up to
MCP_MAX_PAYMENT_PRICE, from a live key — andan agent that reads attacker-influenced text is one sentence from being told
which URL.
paidCallAllowlist(server config,MCP_PAID_CALL_ALLOWED_HOSTS)lists origins or URL prefixes; an unlisted, non-http(s) or credential-bearing
URL is refused with
ERR_HOST_NOT_ALLOWEDnaming the host, before any requestis sent, and the list cannot be widened by a tool argument. The default is
empty, which pays nobody.
c889e70: Multi-hop routing through configurable intermediate assets bounded in depth.
ROUTING_MAX_HOPS, defaulting to 3) across configurable intermediate assets (ROUTING_INTERMEDIATES, defaulting to XLM and Circle USDC).SDEX|AMM), pool ID if applicable, price, spot price, and slippage percentage.ebb21a3: Add
GET /pegs(/v1/pegs) endpoint reporting current and rolling-window worst peg deviations (in basis points) and provenance (venues, sample count, and freshness) across all configured pegged assets (PEGGED_ASSETS_TESTNET,PEGGED_ASSETS_MAINNET,PEGGED_ASSETS).USDCpegged toUSD:1.0per network (USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5=USD:1.0on testnet andUSDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN=USD:1.0on mainnet) whenPEGGED_ASSETS*is unset so deployed instances report the canonical USD stablecoin peg out of the box rather than returning an empty list.DEFAULT_PEG_TOLERANCE_BPS = 100(100 bps = 1.0%) withGET /snapshot(PEG_TOLERANCE_BPS = 100) and the peg-deviation alerting engine (Feat/peg deviation alerts #322, default band = 0.01 = 100 bps).GET /snapshotconsultsNetworkConfig.peggedfirst when evaluating watched-pair pegs so configured targets and non-1.0 target prices never disagree between/pegsand/snapshot, while retaining its built-in 1:1 fiat stablecoin ticker map (PEGGED_ASSETS) as a fallback for unconfigured watched pairs and stablecoin-sibling reference quotes (USDC/USDT).93c55cf: Add Phoenix, a Soroban AMM venue, as a price source.
src/ingest/venues/phoenix.tsenumerates pools from Phoenix's own factory contract (
query_pools) — never from alist kept in this repo — reads each pool's reserves with
query_pool_info, derivesthe constant-product spot price, and writes
price_pointsrows taggedsource: 'phoenix_amm'with the pool's contract id inpoolId. A watched pair ismatched to a pool by deriving the Stellar Asset Contract id from its
{code, issuer}and the network passphrase, so two assets sharing a code but not an issuer can never
blend into one series.
The discovered pool list is cached per network for
PHOENIX_POOL_CACHE_TTL_MS(default 10 minutes) with single-flight refreshing, and a refresh that fails keeps the
previous list and leaves the TTL un-advanced so the next cycle retries. The adapter is
registered in
venueAdapters, soENABLED_NETWORKSstarts one independentlyfault-isolated Phoenix instance per network, each writing rows stamped with its own
network. An unreachable Phoenix RPC degrades this venue to "no Phoenix price today" —
it logs and returns empty rather than throwing.
Config:
PHOENIX_FACTORY_ADDRESS_MAINNETdefaults to Phoenix's published mainnetfactory,
CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI, taken verbatimfrom the protocol's own repository. Phoenix publishes no testnet deployment, so testnet
has no built-in id: the venue stays disabled there until an operator sets
PHOENIX_FACTORY_ADDRESS_TESTNET.PHOENIX_ENABLED_<NETWORK>,PHOENIX_POLL_INTERVAL_MSandPHOENIX_POOL_CACHE_TTL_MSround out the surface, alldocumented in
.env.example.db4126d: Implement a retention policy and background worker for
price_points:price_pointsolder thanPRICE_POINTS_RETENTION_DAYS(default 30 days) into 1-hourprice_aggregatesprior to pruning, preserving historical OHLCV, VWAP, venue volume breakdowns, and trade counts with full numeric precision.PRICE_POINTS_PRUNE_BATCH_SIZE, default 1,000) usingFOR UPDATE SKIP LOCKEDand yields between batches to prevent holding prolonged database locks.networkto utilize indexes and prevent cross-network data contamination.price_points_pruned_totalandprice_points_rolled_up_totallabeled bynetwork.40014f4: Adopt Prisma migrate: commit a baseline migration (
prisma/migrations/0_init), make CI fail on drift betweenschema.prismaand the migrations, switch app startup and deploys toprisma migrate deploy, and remove thedb pushpath.Startup adopts the baseline by itself on a database that predates the migration history — it records the baseline as applied without running it, rather than colliding with tables that are already there — so the order of a merge and a redeploy cannot take the API down. Dropping
db pushalso means schema drift is no longer self-healed at boot:docs/database-migrations.mdhas the one-time check to run against a live database before the first deploy.c5f598d: Build a question-led API route index (No single page shows what Lens knows #305) generated directly from the OpenAPI specification to prevent documentation drift:
openapi.yamlwithx-question,x-example,x-order, andx-category.scripts/generate-question-index.tsandnpm run index:gento compiledocs/what-lens-knows.mdand keep theREADME.mdendpoint table synchronized.tests/openapi.test.tsto assert every Fastify registered route has a plain-English question, fail when a route lacks a question, verify contiguous newcomer ordering, and guard against drift inREADME.mdanddocs/what-lens-knows.md.634ac0c: Every pricing response (
/price/:assetA/:assetB,/price/twap/:assetA/:assetB, and/price/vwap/:assetA/:assetB) now carries:newestInputObservedAt: ISO 8601 timestamp of its newest input data point (nullif no trades exist)inputCount: the count of inputs used in the computationstalenessThresholdSeconds: the staleness bound in secondsstale: boolean flag indicating whether the newest input is older than the staleness thresholdThe staleness bound defaults to 300 seconds (configurable globally via
PRICE_STALE_THRESHOLD_SECONDSorSTALENESS_BOUND_SECONDS) and can be customized per request using thestaleThresholdquery parameter. All routes and schemas are declared inopenapi.yamland synchronized inopenapi.json.83d9bd7: Lens publishes prices. A SEP-40 (
oracle/contract/) oracle contract plus arate-limited, deviation-triggered publisher (
src/publish/) put Lens's ownaggregate on chain so Soroban contracts can read a Lens price, with the same
staleness and venue-provenance guarantees the HTTP API makes.
The contract implements the spec's
PriceFeedTraitverbatim —base,assets,decimals,resolution,price,prices,lastprice— returningNonerather than reverting on input it cannot answer, and adds one provider-side
write:
report(asset, price, timestamp, venues), authorized solely by thepublisher address the admin sets. It re-checks every rule a consumer is told to
rely on: the price is positive, the timestamp is trimmed to
resolution(), isnot ahead of ledger time, and is strictly newer than the last record, and the
venue list is non-empty —
source_countis derived from that list, never passedin.
last_reportexposes the venues, the ledger and the close time behind thecurrent price.
initializeis one-shot andupgradeswaps the WASM withoutchanging the contract address, as SEP-40 requires.
The publisher is a loop like the venue ingesters: one per entry in
ENABLED_NETWORKS, each fault-isolated byrestartIngester, each reading onlyits own network's pairs and rows. Which leg of a pair it quotes and whether the
stored
price = assetB per assetAhas to be inverted are derived from thebase()the deployed contract reports — the scale and tick come from thechain too, and the cycle aborts if they cannot be read, because there is no safe
default for a number a contract settles against. Publication then runs as
guards → triggers → write vetoes: staleness and provenance are checked before
the feed is read at all, so a stale aggregate provably never reaches RPC or
spends a fee; a write needs either a
SEP40_DEVIATION_PCTmove measured againstthe price already on chain or a
SEP40_MAX_INTERVAL_MStimer, and even thenSEP40_MIN_WRITE_INTERVAL_MScaps it at one transaction per asset per interval.SEP40_MAX_AGE_SECONDSdefaults to 120, stricter than the API's 300 s staleflag on purpose.
Every write — and every failed write — lands in a new
oracle_publicationstable with the venues behind it, the scaled value, the trigger, the deviation and
the tx hash. Blocked decisions are logged, not stored: a 30-second poll across
two networks would otherwise be a table of "nothing happened".
Off by default on every network (
SEP40_ENABLED_*=false), needs a deployedcontract id and a funded publisher key, and refuses to start half-configured. No
secret key is committed anywhere, and the docs deliberately contain no feed
contract id. No REST or GraphQL route is added.
7d187ea: Add
GET /snapshot: one cached, network-scoped summary of a network's venues(liveness), AMM liquidity (TVL, per-asset reserves and per-pool detail), 24h
traded volume (per venue, with a single-unit total only when the pairs share a
unit), the busiest pairs with their price direction, stablecoin peg readings,
and data freshness. All figures come from a single database statement with one
as_ofclock, and unknowns are reported asnullwith a reason rather thaninvented. The endpoint is public and never 404s.
Split the CORS policy across three route tiers (One CORS policy covers public reads and authenticated routes alike #268). Public reads stay open to any origin with no credentials; credentialed routes are restricted to
CORS_ALLOWED_ORIGINSwithcredentials: true; admin routes are closed to cross-origin browsers unlessCORS_ADMIN_ALLOWED_ORIGINSnames them. A request carryingAuthorizationorCookieis treated as credentialed whatever it asks for, so a browser cannot be handedAccess-Control-Allow-Origin: *for an authenticated call. A bare*is refused for the credentialed and admin tiers, and a wildcard entry must match the scheme it names.794eb07: Document and separate process health from data health on
GET /status(/statushas no documented shape #273). The response now carriesstatus,health.process,health.data, aprocessblock (uptime, heap) and adatablock (last ledger, lag,isStale); the previous top-level fields are unchanged, so existing consumers keep working. Ingest lag over 300s reportsstatus: "degraded"with a 200, and a Postgres failure returns 503 withpagingCondition: "DATABASE_DOWN"— so an uptime monitor pages on infrastructure, not on upstream ledger delay. The 503 body is a fixed phrase:/statusis public, so the driver's message goes to the log, never the response.health.processis read from boot state, so the first seconds of a deploy report a degraded process with healthy data.2eda5a2: Add a generated TypeScript client at
clients/typescript.npm run client:genregenerates
clients/typescript/src/schema.d.tsfromopenapi.yamlwithopenapi-typescript, andopenapi-fetchgives a typedcreateLensClient, soevery request and response type is derived from the spec and nothing is
hand-maintained. CI regenerates the client and fails if the committed copy
differs from
openapi.yaml, then typechecks it withnpm run client:typecheck.clients/typescript/src/example.tsis a documentedusage example; the README explains how to regenerate and use the client.
1a332bb: Classify venues as primary markets or aggregators and report net and gross volume separately (Volume is summed across venues that may be quoting the same trade #244).
/volumes/:assetnow returnstotals.net(summed across primary venues) alongsidetotals.gross(everything observed), so an aggregator re-reporting a trade that already settled on a primary market no longer inflates the headline figure. Aquarius and DeFindex are aggregators; SDEX, the native AMM, Soroswap and Phoenix are primary. The reasoning for each is documented indocs/venues.md, and a test fails the build if the adapter and the documented classification ever disagree.6c97743: Add trade-size and volume-concentration statistics to
GET /volumes/:asset, with per-network filtering and response provenance.5ea669e: Bound what a slow websocket client can cost the process.
WebSocket#send()neither blocks nor throws when a peer is slow, it queues.Nothing inspected
bufferedAmount, so one subscriber that stopped reading — asuspended mobile client, a wedged TCP window — accumulated price updates in its
socket buffer without limit, and the message was counted as sent the moment it
was queued. With the feed pushing continuously, one stuck subscriber was enough
to grow the process until it was OOM-killed, taking every other subscriber and
every HTTP client with it.
FanOutManagernow applies a per-client policy insrc/ws/backpressure.ts:past
WS_SLOW_CLIENT_BUFFER_BYTES(256 KiB) updates are coalesced to the newestone, past
WS_MAX_BUFFERED_BYTES(1 MiB) — or congested for longer thanWS_MAX_CONGESTED_MS(30s) — the client is closed with1013and counted inws_clients_dropped_total. Superseded updates count inws_messages_dropped_total, so a truncated feed is visible rather than silent,and
ws_buffered_bytesreports total queued bytes. All the limits areconfigurable, and none of them can slow down delivery to any other client.
ws_messages_sent_totalnow increments when the socket reports the writecompleted rather than when
send()returned, so it counts what was writtenrather than what was queued. The websocket metrics also moved from
prom-client's global default registry onto the one
/metricsrenders, which iswhere they were supposed to be visible all along.
Patch Changes
45aae33: Guard admin endpoints structurally (Admin routes are public and that is load-bearing #514). An
onRequesthook enforcesADMIN_TOKENover every route that declares{ admin: true, auth: 'admin' }or is registered under/admin/, so a new admin endpoint is protected by where it lives rather than by its handler remembering to callisAdminAuthorized.public: truenow means genuinely unauthenticated, and all seven admin routes declare the admin tier instead. The guard reads the matched route pattern, notreq.url, so a percent-encoded path cannot route to an admin handler while reading as non-admin.Distinguish an unconfigured admin surface from a rejected token (An unset ADMIN_TOKEN disables the admin surface silently #508).
checkAdminAuthreturnsauthorized/unauthorized/not_configured, and admin routes answer 503admin_not_configuredwhenADMIN_TOKENis unset, blank or an obvious placeholder, instead of a 401 that tells an operator their correct token is wrong. The structural admin hook makes the same three-way distinction, startup logs a warning when the surface is disabled, andGET /statusreportsadminConfigured— whether a token is set, never any part of its value.e091749: Partition the aggregation layer by network. Every function in
src/aggregator/vwap.tsnow takes a requirednetworkand filtersprice_points/pool_snapshotson it, andgetAMMPricefilters both thepool_snapshotsscan and thepool_idsubquery. Previously an unfiltered readreturned a volume-weighted blend of both chains' rows for a pair key that
exists on both, so
/price/:a/:banswered with a number nobody traded at andstamped it
"network":"mainnet"./price/:assetA/:assetBpassesreq.networkthrough to the aggregator, so thenetworkfield in the response is the one the numbers came from. The aggregaterefresh worker now runs once per enabled network — its own queue name, its own
pair list, and
price_aggregatesupserts stamped with its network — where itpreviously pinned all three to
STELLAR_NETWORK, leaving the second network's/price/*/historyand/screenerpermanently empty. The Redis price key itwrites is network-prefixed, matching what the REST route reads.
90f3269:
bazaar_searchno longer fetches any URL it is handed (bazaar_searchwill fetch any URL it is given #407). The MCP server nowparses the target, refuses anything that is not
http/https, resolves thehostname and rejects it if any address is private, loopback, link-local, or a
cloud metadata endpoint (e.g.
169.254.169.254), and follows redirects manuallyso a permitted host cannot bounce the request to a forbidden address. Operators
can constrain reachable hosts further with the shared
OUTBOUND_ALLOWED_HOSTSlist, which narrows access but never re-opens a blocked address. Blocked requests
return the new
ERR_URL_BLOCKEDcode.71f8e42: An empty
bazaar_searchresult is a success, not an error (An empty result is reported as an error #414). A search that ran correctly and matched nothing returnedisError: truewithERR_NO_RESULTS, which an agent answers by retrying or rephrasing — a paid call spent on a question already answered. It now returns the empty collection.ERR_NO_RESULTSis unchanged forlens_get_price, where a refused pair really is an error.8e8d8ab: Enforce ratePerDay daily quota on authenticated API keys (
ratePerDayis stored, returned, advertised — and never enforced #175)1c883e3: A failed venue fetch is no longer indistinguishable from an empty venue (A failed venue fetch is indistinguishable from an empty venue #329). The Horizon AMM pool fetch, the Soroswap token list, the Aquarius pool fetch and the Reflector oracle read now all return one result shape,
{ ok: true, data } | { ok: false, error }, instead of turning an error into[]ornull. A failure is logged (Reflector used to swallow it silently) and recorded per source, network and pair; the next successful fetch clears it.aggregatePriceexcludes a failed source from the composite and from the deviation baseline instead of counting it as though it answered with nothing, and returns the failed sources asfailedSources.GET /statuslists them underfailedSources(source, network, pair or asset, time; the error text stays in the log because the route is public).7ba1f47: Preserve TWAP timestamp alignment when outlier prices are rejected.
4ddc5c2: Generate README venues section and endpoints table from code registries:
scripts/generate-readme.ts(npm run readme:gen) to render the venues table fromvenueAdaptersand the endpoints table fromopenapi.yaml.typeanddescriptiontoVenueAdapterand existing venue adapters (SDEX, AMM, Soroswap, Aquarius).tests/readme.test.tsto assert thatREADME.mdvenues and endpoints sections never drift from source registries.a6dcd93:
GET /prices/historyaccepts?format=csv, returning the points as atext/csvbody (ts,price,volume, RFC 4180 quoting, LF endings, one trailingnewline); JSON stays the default and an unknown format is rejected with 400
before the database is queried. The serializer is
historyToCsv()insrc/api/history.ts, andtests/historical.golden.test.tsbyte-compares it —plus the HTTP response — against
tests/fixtures/historical-*.csv, so columnorder, timestamp and number formatting, and line endings cannot drift without
failing the suite.
d546b0b:
GET /prices/historynow honours?network=/x-networkinstead of alwaysquerying the deployment's default network, and echoes the resolved
networkin the response.
queryHistory()takesnetworkas a parameter.Retry Horizon rate limiting instead of reading it as empty data (Horizon rate limiting is not handled #348).
fetchHorizonJsonhonoursRetry-Afterwhen Horizon sends one and otherwise backs off exponentially with jitter, raisesHorizonRateLimitErroronce the attempts are spent so the runner counts and backs off the whole cycle, and exportshorizon_rate_limit_remainingfrom theX-RateLimit-Remainingheader. Requests go throughfetchWithTimeout, so they carry the configured HTTP timeout and the cycle deadline's abort signal — including during the retry wait.5ef1591: Add a
networklabel to the four ingest metrics —trades_ingested_total,amm_snapshots_total,price_snapshots_totalandlast_trade_timestamp— so adual-network deployment (
ENABLED_NETWORKS=testnet,mainnet) reports eachnetwork separately instead of folding them together.
last_trade_timestampis the one that mattered: it is a gauge, so the testnetingester's
set()was overwriting mainnet's value for the same pair every pollinterval, making Lens's only staleness signal silently unusable — it still
looked like a plausible timestamp. The counters were less broken but equally
unqueryable per network, since the two loops summed into one series.
Cardinality is unchanged in character:
pairs x networks(pluspoolsonamm_snapshots_total), all bounded byWATCHED_PAIRSandENABLED_NETWORKS.No
issuerorpoollabel was added. Existing queries that did not filter bynetworkkeep working and now return one series per network instead of amerged one, so a recording rule that aggregated across networks before will see
one additional series per pair.
Documented in
docs/ingest-metrics.md.9601518: Validate ingest price ticks with rolling-median plausibility check and persist outliers to quarantine table (Outlier rejection exists only at read time #238).
Venue ingestion failures are logged through pino on the
ingester:<venue>child logger, withvenue,network,pair,attempt,retryMs,durationMsanderras queryable fields, so an operator can count failures per pair and see the stack. The convention is documented indocs/logging.md.28af3ba: MCP tool results are size-bounded (Tool results are unbounded #416). Every result was serialised
pretty-printed and in full, so one large — or hostile — response could consume an
agent's entire context window, and the failure looked like a bad model rather
than an unbounded tool. All four tools (
bazaar_search,paid_call,payment_budget,lens_get_price) now return compact JSON capped atMCP_MAX_RESULT_BYTES, and a truncated result says so:truncated: true, thetotal, the page returned, and a notice explaining how to ask for more.
bazaar_searchgainslimit/offsetarguments (defaults and ceiling fromMCP_DEFAULT_PAGE_LIMIT/MCP_MAX_PAGE_LIMIT), forwards them to the node, andpages whatever comes back even if the node ignores them.
lens_get_pricekeepsstructuredContentonly while the payload fits, since shipping it regardlesswould move the blow-up into another field.
Upstream bodies are read through a capped stream reader (
MCP_MAX_UPSTREAM_BYTES)that keeps the part that fits and cancels the rest, so an oversized body is never
buffered in full. A discovery body too large to parse returns
ERR_RESPONSE_TOO_LARGE, which — unlikeERR_URL_BLOCKEDandERR_HOST_NOT_ALLOWED— is not a refusal to ask: the request succeeded, theanswer is simply too big to hand over.
59cc2a0: Every
paid_callnow leaves a record of what it cost. A successful call used toreturn the response body and nothing else: no log line, metric or persisted trace
of which URL was paid, how much, on which network, with which transaction. For a
tool that signs with a real key, the audit trail is not an enhancement — without
it there is no answer to "where did today's money go", and the cumulative ceiling
had no measurement of spend to read.
PaymentLedger(src/mcp/ledger.ts) writes one JSON line per attempt toMCP_PAYMENT_LEDGER_FILE(default~/.lens-mcp/payments.jsonl): timestamp, fullURL, host, method, price, asset, network, transaction hash, the call's outcome,
what the ledger can vouch for about the money (
settled,possibleornone)and the error code if the call failed. A failed call is recorded too, because a
payment can settle on a call that then errors —
outcomeandspendare separatefields so "the call failed" never implies "nothing left the account".
The price is not on the
Responsethe x402 client hands back; it is only visibleon the payment header the transport puts on the request. So the ledger wraps that
transport's fetch (
instrumentFetch) and correlates the offer with the responseit produced. The settlement header is read for the transaction hash and for
whether the seller confirmed it.
mcp_payment_attempts_total(labelshost,network,outcome) andmcp_payment_amount_usd_total(labelshost,network) export the same spend toPrometheus on the shared registry, which
MCP_TRANSPORT=httpnow serves from theMCP process itself at
GET /metrics— a counter nobody can scrape is not anexport. That route answers only to the same bearer token as the tools: what an
agent bought, on which host and for how much describes a key that moves money.
MCP_HTTP_PATH=/metricsis refused at boot rather than resolved by precedence, soone path never has two meanings. Under stdio there is no listener, so the running
total there is what
payment_budgetreports and what the audit file holds.Labels come from
MCP_METRIC_HOSTSandMCP_METRIC_NETWORKS(defaultstellar:testnet,stellar:pubnet, the networks this agent signs for): series arepermanent, so labelling by whatever URL an agent asks for would let one caller mint
unbounded time series, and anything outside an allow-list is reported as
unknown.The audit file always keeps the real host. Amounts that are not a USD money string
are recorded verbatim but cannot join the USD sum, so
mcp_payment_unquantified_totaland
totalSpend().unquantifiedPaymentsmake that understatement visible instead ofsilent.
No secret material is recorded. Only the seller's echoed
acceptedrequirements areread out of the payment header; the signed envelope alongside it never reaches a
record, a metric or the file, and every header field is length-capped so a seller
cannot forge extra lines into the trail.
Accounting fails closed in both directions: if the audit file cannot be created or a
record cannot be written,
paid_callis refused withERR_INTERNAL_FAILUREratherthan spending unrecorded. And
payment_budgetnow reportsrecordedSpendbeside thebudget's own worst-case reservations, so the ceiling has an actual measurement to
read: the budget holds the per-call maximum because it cannot see the price, while
the ledger reports the price it did see.
2337629: The MCP server's spend cap is now cumulative, not just per call.
maxPricebounded a single payment, so an agent in a retry loop — or one told to "try each
of these endpoints" — could pay that amount arbitrarily many times: every
payment individually inside the limit, the sum unbounded. A per-call limit
answers "how much can one mistake cost"; an operator's question is "how much can
today cost".
SpendBudget(src/mcp/budget.ts) adds a ceiling on the total paid bypaid_callwithin one UTC day, configured withMCP_DAILY_SPEND_CEILING(default
$10.00) alongside the per-callMCP_MAX_PAYMENT_PRICE. The day windowwas chosen over a sliding 24 hours because it answers the operator's question
directly, needs one stored number rather than one entry per payment, and rolls
over from the wall clock — so a restarted server resumes the same day's total and
resets it after 00:00 UTC.
Before a call is made the budget reserves its worst case, the per-call maximum,
and refuses when that no longer fits what is left. It refuses with the new
ERR_BUDGET_EXCEEDEDcode carrying the spent, ceiling, remaining and reset timein
details.budget, so an agent can report being out of budget instead oflooking broken; the refusal happens before the payment fetch, so no money moves.
Both limits are in the
paid_calldescription and the live totals are readablewithout spending, through the new
payment_budgettool. The running totalpersists to
MCP_SPEND_STATE_FILE(default~/.lens-mcp/spend-state.json) and isread back at startup, because a restart does not refund what the previous process
already spent.
Accounting is deliberately conservative: the wrapped x402 transport exposes only
a plain
Response, so a paid call that could have settled is booked at theper-call maximum and a rejection that proves nothing settled is not booked at all.
Recorded spend is an upper bound, which means the ceiling is reached early rather
than late — and an unreadable or unwritable state file is treated as fully spent,
not as a clean slate.
Also fixed while wiring this up:
MCP_MAX_PAYMENT_PRICEwas passed towrapFetchWithPaymentFromConfigasmaxPrice, which is not an option of thex402 client config and was silently ignored — every payment was capped by the
library default of
$1whatever the operator asked for. It is now passed asspendControls.maxAmountPerPayment, so the per-call limit the budget reservesagainst is the one actually enforced.
3c43cfe: Fix network-blind webhooks and price tracking across SDEX, Horizon AMM, Soroswap, and Aquarius ingesters:
lastPricemaps by(network, pairKey)so price ticks on one chain do not supply the previous price for another chain.POST /webhooks) with validation.9af1b08: Fix numeric price and volume precision across aggregations (Prices are stored to 18 decimal places and computed as floats #230).
::floatcasts inside intermediate aggregate expressions in/candles,/prices/history, and GraphQLgetPriceHistory.::floatcasts from/price/:assetA/:assetB/historyand query raw numerics directly fromprice_aggregates.src/pricing/precision.ts: prices up to 18 decimal places matchingDecimal(36, 18), and volumes up to 7 decimal places matching Stellar stroops (Decimal(36, 7))./volumes/:asset.2ea353b: Document the full public route surface in
openapi.yaml(params, the shared?network=query, and response schemas) and addtests/openapi.test.ts, whichboots the app, enumerates every registered route and fails when a non-internal
route is missing from the spec. Operator-only and non-HTTP routes are excluded
through an explicit, commented allow-list in
src/openapi/coverage.ts. Thegenerator is now importable and deterministic, and a test asserts the committed
openapi.jsonmatches it.7ecd06d:
GET /pairsandGET /poolsno longer scan the whole history of every networkon each request. Both now filter by network (
req.network, default the activenetwork) and read one index entry per pair / pool, so cost no longer grows with
table size. Response shape is unchanged. A pair or pool that has gone quiet is
still listed with its real last-update timestamp (it is never dropped).
e3af84f: Make duplicate price points impossible to store (Nothing stops the same trade being written twice #237).
price_pointshad nouniqueness on the trade identity, so an ingester retrying after a partial
failure — or two instances briefly overlapping during a deploy — wrote the same
trade again, inflating volume and skewing the volume-weighted VWAP.
PricePointnow declares@@unique([network, source, eventId])(index
price_point_identity), andupsertPricePointswrites as an idempotentinsert (
skipDuplicates→ON CONFLICT DO NOTHING), so a replayed batch isabsorbed rather than stored twice.
Postgres treats NULLs as distinct in a unique index, so a row stored without an
event id could never be recognized as a duplicate. Writes therefore never store
a null: venues with a natural id (SDEX/AMM trade TOIDs) pass it through,
snapshot venues without one derive their key from pool STATE (AMM: pool id +
last_modified_ledger; Soroswap: pool address + reserves) instead of theprevious
Date.now()ids, which minted a fresh identity per attempt. Sourcesthat supply no id at all (Aquarius, future venues) get a SHA-256 synthesized
deterministically from the row's content — never a clock, never random.
scripts/migrations/2026-10-17-price-point-identity.sqlremoves rows alreadyduplicated on this identity (keeping the earliest per group) before creating
the index, for databases where
prisma db pushwould otherwise fail. Rowspredating the change with a NULL
event_idare left untouched; from here on nonew row can have one.
2c9d8a4: Snapshot retention now prunes
price_snapshotsfor every network inENABLED_NETWORKS, not just the active one, so the second network of adual-network process no longer grows unbounded. The startup prune, the BullMQ
worker and the in-process fallback timer all use the new
pruneAllNetworks(),and the pruned row count is logged per network.
pruneOldSnapshots()gains anoptional
networkargument (defaulting to the active network).0e1959b:
npm run openapi:gennow also regenerates the README endpoint table fromopenapi.yaml, and CI runsnpm run readme:check, which fails when the committed README differs from a fresh generation (The README's endpoint table is maintained by hand #275).9abd1f0:
/screenerno longer reportsmarket_cap. It was a copy ofliquidityunder another name, so sorting or filtering by market cap silently sorted or filtered liquidity. Lens has no circulating-supply data to compute a real one, so the field is removed from the response and the sort allowlist, and?market_cap=and?sortBy=market_capnow return a 400 instead of being ignored. The three CTEs behind/screenerare also now scoped to one network (?network=/x-network, defaulting to the active network) instead of pooling testnet and mainnet rows.d53ac14: Fix
slippagePcton/price/:a/:b/route, which was always exactly 0 becausethe execution price was compared with itself. It is now the shortfall of the
execution price against the AMM reserve-ratio spot price, so it grows with
order size against a fixed pool. Route selection and
estimatedOutputareunchanged. Slippage is only reported for an AMM route; SDEX and SPLIT routes
(and pairs with no AMM pool) have no size-independent spot reference, so the
value stays 0 rather than reporting a cross-venue spread as slippage.
61c1854: Fix
GET /status: scopeindexer_stateto the requested network (?network=/
x-network, defaulting toSTELLAR_NETWORK) instead of returning whicheverchain wrote most recently, and report the answering network and its watched
pairs in the response.
The SDEX and AMM ingesters now derive each trade's ledger from the TOID prefix
of its Horizon id — Horizon sends no
ledgerfield on a trade — so price pointscarry a real ledger again and
lastIndexedLedgeris no longer permanently null.A malformed id falls back to the last stored ledger, and the batch is deferred
if no ledger can be established. A new
ingestLagSecondsfield surfaces astalled ingester without Prometheus.
3cdb4bd: Route all logging through pino (
src/logger.ts) instead ofconsole.*. Everymodule now logs through a component-scoped child logger —
ingester:sdex,job:aggregate-refresh,ws:fanout,redis,config,webhook,route:price, … — emitted as a structuredcomponentfield rather than amessage prefix, so operators can filter by component without regexing text.
Fastify's request logger is built from the same options, so
req.logcarriesthe same redaction and formatting.
The level is configurable via
LOG_LEVEL(defaultinfo, documented in.env.example): logs are newline-delimited JSON whenNODE_ENV=productionandpretty-printed for humans otherwise. Values of secret-looking environment
variables (
DATABASE_URL,DIRECT_DATABASE_URL,REDIS_URL, and any namecontaining
KEY/SECRET/TOKEN/PASSWORD) are scrubbed from everyserialized line, including the password inside a connection string and driver
error messages that echo it, while request auth headers are redacted via
pino's
redact. A test asserts noconsole.*survives in non-test source andthat a connection string or API key value never reaches the output.
29e834a: Config now validates every Soroban contract id it hands out (Soroswap factory,
Reflector oracle) with
StrKey.isValidContract. A malformed id no longer bootsthe feature "enabled" and fails on every RPC call: it logs a warning naming the
env var and disables that feature on that network. Two built-in defaults were
55 characters and therefore invalid; the testnet Soroswap factory default is
replaced with the address from Soroswap's published testnet deployment, and the
mainnet Reflector default is removed, so the oracle stays off until
REFLECTOR_CONTRACT_ID_MAINNETis set..env.exampleand the deploy docs areupdated.
789b054:
GET /volumes/:assetnow filtersprice_pointsby network instead of summingtestnet and mainnet volume together. It accepts
?network=testnet|mainnet(default: the active network), returns 400 for any other value, and echoes the
resolved
networkin the response body.dca5ff3: Add
docs/x402-conformance.md: a conformance baseline for the publicx402.orgfacilitator on
stellar:testnet, established by settling a real payment throughit with an unmodified
@stellar/stellar-sdkclient and feeding it deliberatelybad input. Records
/supportedverbatim, a settled transaction hash, areason-per-rejection table, and five divergences between the reference
implementation's advertised and actual behaviour. This is the baseline the Lens
facilitator (x402 facilitator: implement GET /supported #124, x402 facilitator: implement POST /verify (including __check_auth contract accounts) #125, x402 facilitator: implement POST /settle (design note first) #126) is measured against.
94bd8ea: Add an end-to-end test for the successful x402 402 payment loop (No test drives a real 402 payment loop #423). Drives the real
@x402/fetchtransport against a stubbed resource + fake facilitator, signing a real Sorobantransferwith@stellar/stellar-sdkusing throwaway testnet keys. Asserts on what was signed —amount, destination and network passphrase (valid on testnet, invalid on mainnet) — not just the
response body, and covers the failure branches: amount above maxPrice refused before submitting,
a network the client is not registered for, and an expired signature that the facilitator rejects.