Skip to content

feat(sponsorship): pay the Celestia fee only for offers priced below reference - #55

Merged
acedward merged 5 commits into
00005-token-price-service-a-feedfrom
00005-token-price-service-b-batcher-gate
Sep 3, 2026
Merged

feat(sponsorship): pay the Celestia fee only for offers priced below reference#55
acedward merged 5 commits into
00005-token-price-service-a-feedfrom
00005-token-price-service-b-batcher-gate

Conversation

@acedward

@acedward acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What

The batcher pays the Celestia fee only for offers whose wanted value is at least
the sponsorship discount below the given value at the node's reference prices —
and the node says the same thing earlier, and more clearly.

Until now checkForCelestiaSponsorship was a no-op stub: every structurally
valid offer was sponsored
, including one priced exactly at reference or above
it. And when the batcher did refuse for any reason, POST /v1/offers answered
500 INTERNAL — a status that tells the maker "server problem, retry
unchanged", which is the opposite of what to do.

Builds on #54 (base is that branch; retarget to main once #54 merges).

Not breaking, by construction

The rollout defaults are BATCHER_SPONSOR_POLICY=warn and
BATCHER_SPONSOR_UNPRICED=allow, so on any deployment that sets nothing:

  • nothing is refused that is not refused today — warn only logs what
    enforce would have refused;
  • unpriced tokens (every faucet-minted test token) keep flowing.

The intended rollout is: deploy with the defaults, read a day of
would refuse (policy=warn) — NOT_SPONSORED: … lines from both processes, then
set enforce.

The one caller-visible change: ZswapCelestiaAdapter.validateInput is now
async. The SDK core already awaited it (batcher-sdk core/batcher.ts:513), so
nothing on the production path changes; direct callers (three test files) await
it now.

How

One rule, three readers, no drift. evaluateSponsorship() in
@zswap-da/offer-guard — added in #54 and already behind /v1/quote's
sponsored flag — is now also what the batcher and the node's pre-check ask.

  • packages/batcher/price-lookup.ts (89cfc3e; was price-cache.ts) — the
    batcher has no database and does not mirror the price table: for each
    offer it collects the leg colours and fetches the missing or expired ones in
    ONE GET /v1/prices?tokens=… request, cached per colour for
    BATCHER_PRICE_TTL_MS (10 min default). sponsor_discount and the feed
    status are refreshed from every response. A failed refresh keeps the cached
    entry and lets it age; past BATCHER_PRICE_MAX_AGE_MS it stops counting
    as an answer (a colour with no usable entry is unavailable; a colour the
    node does not price is unpriced). One startup probe (?tokens=<NIGHT>)
    answers "am I price-aware?" — the only pre-offer request.
  • ZswapCelestiaAdapter.validateInput — the gate runs LAST, after proof
    verification. It is the cheapest check in the ladder, but it needs
    gives/wants, and refusing an offer with values read out of an unverified
    transaction would be refusing it with numbers that were never real.
  • POST /v1/offers — a mirror, not a second opinion: same function, same
    resolution order as the quote and /v1/prices, same two env variables. It
    runs after the byte-identical dedup probe and before liveness/crypto, and is
    read-only — unlike the quote it never writes a demo price row, so a
    refused submission cannot create the row that makes the next one look priced.
  • batcher-client.tsNOT_SPONSORED / UNPRICED_TOKEN /
    PRICE_UNAVAILABLE from the batcher now surface as 422 carrying the
    batcher's own message. Every other batcher failure is still a 500; it is not
    the maker's fault.

New 422 body on POST /v1/offers:

{ "error": "NOT_SPONSORED",
  "reason": "wants 0.0% below reference, sponsorship needs ≥ 2.5% below",
  "give_usd": 19181.81, "want_usd": 19181.81,
  "implied_discount": 0, "sponsor_discount": 0.025 }

This is NOT enforced at STM ingestion, deliberately

The MIP-0006 namespace is permissionless: an unsponsored offer posted straight
to Celestia at the maker's own expense is still indexed. The gate decides who
rides the batcher's wallet for free, not what is a valid offer. API.md's
"step 5 — not implemented" paragraph is rewritten to say exactly this, so a UI
does not start assuming every indexed offer was sponsored.

Policy

Variable Values Default Read by
BATCHER_SPONSOR_POLICY enforce | warn | off warn node + batcher
BATCHER_SPONSOR_UNPRICED allow | reject allow node + batcher
BATCHER_NODE_API_URL URL http://127.0.0.1:9999 batcher
BATCHER_PRICE_TTL_MS ms 600000 batcher
BATCHER_PRICE_MAX_AGE_MS ms 172800000 (48 h); must be ≥ the TTL (startup error otherwise) batcher
BATCHER_PRICE_REFRESH_MS dropped in 89cfc3e (inert if still set)
SPONSOR_DISCOUNT_BPS 0–9999 250 node; batcher bootstrap only

The two processes read the same names, and the parsing lives in
@zswap-da/offer-guard so they cannot disagree about what a value means. An
invalid value throws at startup: an operator who typed enfroce wants
offers refused, and silently sponsoring everything is what they were
preventing. On the batcher SPONSOR_DISCOUNT_BPS is only a bootstrap — once
the node answers, the node's sponsor_discount wins, so the batcher cannot
refuse at a threshold the UI never showed the maker.

compose.yml passes the knobs to both kernel and batcher. There is
deliberately no depends_on from batcher to kernel — the kernel already
points back at the batcher and the pair would deadlock; the startup probe is
non-fatal by design.

Tests

bun test packages/database packages/node packages/offer-guard packages/price-feed packages/batcher packages/validator
541 pass / 2 skip / 0 fail, 3798 assertions, 54 files
(main baseline 378, after #54 474 — +67 here).
bun run typecheck 0 diagnostics on all three gates; bun run check:pgtypes green.

New suites: packages/batcher/price-cache.test.ts (25),
packages/batcher/sponsorship-gate.test.ts (21),
packages/node/offers-sponsorship.test.ts (21).

Everything is driven with the real proven offer fixture
(packages/validator/fixtures/valid-offer.bech32, gives 1 000 000 NIGHT / wants
5 000 000 of a test colour), so the price of the wanted leg is the only dial and
give_usd = 19181.81 can be checked by hand.

Red before green: on the base branch that fixture is sponsored
(validateInput -> {"valid":true}) and POST /v1/offers forwards it to the
batcher
even with BATCHER_SPONSOR_POLICY=enforce set, because nothing reads
it. Both assertions now flip.

Verified against real processes

The real node and the real batcher binary were run as two processes over
real sockets on random free ports, with only Celestia absent (this host could
not take a second full devnet — another agent's Midnight stack was up). Real
HTTP between them throughout; torn down after.

  • The batcher really looked prices up per offer (re-run on 89cfc3e): at
    startup only the probe GET /v1/prices?tokens=0000…0000; validating the
    at-reference offer produced exactly GET /v1/prices?tokens=ffff…ffff (the
    want leg — the give leg was already cached); the repeat produced no request
    (TTL). GET /v1/prices never appears without ?tokens=. (The first run on
    e80fe37 used the table mirror: prices: 4 tokens, discount 2.50%.)
  • At reference: node 422 NOT_SPONSORED; the same blob straight to
    /send-input400 … "NOT_SPONSORED: wants 0.0% below reference …", and
    zero Celestia submissions — the SDK threw InputValidationError at
    core/batcher.ts:513.
  • 2.5% below: both gates pass and the offer is queued
    (Processing 1 inputs for target: celestia); the only failure left is the
    deliberately unreachable Celestia RPC.
  • Node warn + batcher enforce: the node logs, forwards, and answers 422
    with the batcher's own sentence.
  • Unpriced under the defaults: 200 queued, plus
    sponsoring an unpriced offer (… BATCHER_SPONSOR_UNPRICED=allow).

That run found a real bug (fixed in e80fe37): the SDK wraps an adapter
refusal as {"error":"Validation failed","message":"NOT_SPONSORED: …"}, and the
first version of the mapping read only error — so it matched
"Validation failed" and still answered 500. Both fields are scanned now, and
the test fixture is the verbatim body a live batcher returned.

Not done here

  • bun run test:grand — needs a fresh full Midnight + Celestia devnet
    (~88 minutes exclusive). This machine is shared and its Docker was hosting
    another stack. This branch adds no tables and no SQL, so the suite's three
    static schema lists are untouched, and under the shipped defaults neither
    process refuses anything.
  • "published and indexed" end to end — needs a Celestia devnet; it is a P5
    exit criterion on preprod, together with the enforce switch and the
    browser walkthrough.

Found in passing, not fixed

packages/batcher is in no typecheck gate and does not currently typecheck
(4 pre-existing errors in celestia.ts and midnight-balancing.ts). One real
error in this branch's new code was caught only by an ad-hoc tsc run. Written
up separately; adding the gate means fixing those four first, which is not this
change's scope.

@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Base note: this PR targets 00005-token-price-service-a-feed (PR #54), not main, because it builds directly on that branch's evaluateSponsorship() and GET /v1/prices. Once #54 merges, retarget this PR to main — the diff against main will then be exactly the commits listed here.

@acedward
acedward force-pushed the 00005-token-price-service-b-batcher-gate branch from e80fe37 to b84d34e Compare September 2, 2026 22:55
@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the corrected base (Q-10: USDM is coingecko:usdm-2, fixed removed)

Head is now be29306 (was e80fe37), base branch 00005-token-price-service-a-feed @ d35f864.

(Rebased twice: first onto 1b38a67, giving b84d34e; then onto d35f864 after a two-line doc follow-up landed on A.)

Why: @acedward ruled that every price is USD (the numeraire) and that USDM on Midnight is Moneta's Cardano USDM carried by the VIA Labs bridge — usdm-2 — not a $1 peg. The base branch drops the fixed source entirely; see #54's comment for that change.

The rebase

git rebase onto the updated A branch. One conflict, in .env.mainnet.example: the correction rewrote the PRICE_FEED_MAP example line (…=usdm:6…=usdm-2:6) on exactly the line this branch appends its fee-sponsorship block after. Resolved by keeping both — the corrected line, then this branch's block. No other file conflicted; packages/node/prices.ts and packages/offer-guard/sponsorship.ts auto-merged.

One real fix this branch needed

packages/batcher/price-cache.ts listed fixed in MARKET_SOURCES, typed PriceSource — which no longer has that member, so it stopped typechecking. Market sources are now feed | seed | manual (be29306).

Worth stating explicitly, because it is a behavioural question rather than a rename: a fixed row from an older node is not silently trusted after this. It falls into the existing "unknown source" path, is downgraded to fallback, and the offer becomes unpriced — so BATCHER_SPONSOR_UNPRICED decides instead of a Celestia fee being paid against a price this build cannot vouch for. That is the safe direction, and it is the same path the existing oracle-v2 test pins. In practice it cannot happen anyway: the node and the batcher ship from one image.

Gates on the rebased tip

Check Before the rebase (e80fe37) Now (be29306)
bun test packages/database packages/node packages/offer-guard packages/price-feed packages/batcher packages/validator 541 pass / 2 skip / 0 fail 542 pass / 2 skip / 0 fail, 3801 expects, 54 files
bun run typecheck 0 / 0 / 0 node 0, solver-set 0, price-feed 0
bun run check:pgtypes green green (this branch touches no SQL)

The +1 is inherited from the base branch — a new schema test asserting the asset_prices.source CHECK rejects 'fixed'. Nothing in this branch's own test set changed except the source list in price-cache.test.ts.

Unchanged: the 422 NOT_SPONSORED contract, the warn + allow shipped defaults, and the pinned note to retarget this PR to main once #54 merges. The live SC-002 run recorded on this PR was made against the pre-correction base; only the stablecoin's asset id and price changed underneath it, so the gate's behaviour at reference and at 2.5% below is unaffected — it was not re-run.

@acedward
acedward force-pushed the 00005-token-price-service-b-batcher-gate branch from b84d34e to be29306 Compare September 2, 2026 22:58
…rence

The batcher holds the wallet that pays for every published offer, and until
now it sponsored anything structurally valid: checkForCelestiaSponsorship was
a no-op stub. It now applies the same rule the node's quote already shows the
maker — evaluateSponsorship() from @zswap-da/offer-guard — so the UI cannot
promise sponsorship the batcher then refuses, or vice versa.

The batcher has no database, so it polls the node's GET /v1/prices (PriceCache,
every 10 minutes by default) instead of resolving prices itself. A failed
refresh keeps the last good snapshot and lets it age, which is what makes
"the node answered ten minutes ago" distinguishable from "the node has been
down for two days".

Policy, all validated at startup so a typo throws instead of silently
sponsoring everything:

  BATCHER_SPONSOR_POLICY    enforce | warn | off   (default warn)
  BATCHER_SPONSOR_UNPRICED  allow | reject         (default allow)
  BATCHER_NODE_API_URL, BATCHER_PRICE_REFRESH_MS, BATCHER_PRICE_MAX_AGE_MS
  SPONSOR_DISCOUNT_BPS      bootstrap only, until the node answers once

Defaults keep every current deployment behaving as it does today: warn logs
what enforce would have refused, and unpriced test tokens keep flowing.

validateInput is now async. The SDK core already awaited it, so nothing on the
production path changes; direct callers (the tests) await it now.
…'s refusals

A maker whose offer is not worth a Celestia fee learned it, until now, as a
500 INTERNAL after the blob had already crossed the network — a status that
tells them "server problem, retry unchanged", which is the opposite of what to
do. POST /v1/offers now asks the question itself, before liveness and proof
verification, and answers 422 with the numbers:

  { "error": "NOT_SPONSORED", "reason": "wants 1.0% below reference,
    sponsorship needs >= 2.5% below", "give_usd": ..., "want_usd": ...,
    "implied_discount": 0.01, "sponsor_discount": 0.025 }

It is a mirror of the batcher's gate, not a second opinion: the same
evaluateSponsorship over the same resolution order the quote and /v1/prices
use, and the same two policy variables the batcher reads
(BATCHER_SPONSOR_POLICY, BATCHER_SPONSOR_UNPRICED). Under the default `warn`
the node logs what `enforce` would have refused and forwards, so this release
changes no deployment's behaviour until the policy is switched.

The pre-check is READ-ONLY: unlike the quote it never writes a demo price row,
so a refused submission cannot create the row that makes the next one look
priced.

When the batcher refuses after the node forwarded (a node in `warn` with a
batcher in `enforce`, or a stale batcher snapshot), NOT_SPONSORED /
UNPRICED_TOKEN / PRICE_UNAVAILABLE now surface as 422 carrying the batcher's
own message, instead of 500.

Also: policy parsing moved into @zswap-da/offer-guard so the two processes
cannot disagree about what a value means; compose and the three env examples
carry the new variables; API.md's "step 5 is not implemented" paragraph is
replaced by what was actually built, including why this is NOT enforced at STM
ingestion (the namespace is permissionless — an unsponsored offer posted
directly is still indexed).
Found by running a real batcher against a real node (Brief B, B4), not by
reading code. The batcher SDK wraps an adapter refusal:

  {"success":false,"error":"Validation failed",
   "message":"NOT_SPONSORED: wants 0.0% below reference, …"}

`error` is the generic wrapper text, so the mapping added in the previous
commit matched nothing and the maker still got 500 INTERNAL — the exact defect
it was written to remove. Both fields are now scanned, and the test fixture is
the verbatim body a live batcher returned.
Follow-on from the Q-10 correction on the base branch: `PriceSource` no
longer has a `fixed` member, so listing it in MARKET_SOURCES stopped
typechecking. Every asset price is now a fetched USD price and nothing is
pinned to a dollar, so the market sources are `feed | seed | manual`.

A `fixed` row from an older node is not silently trusted after this: it
falls into the existing "unknown source" path and is downgraded to
`fallback`, i.e. the offer is unpriced and BATCHER_SPONSOR_UNPRICED
decides. That is the safe direction — no fee is paid against a price this
build cannot vouch for.
…e table

Q-11: `PriceCache` polled the node's whole `GET /v1/prices` every ten minutes
and held a snapshot. That is fine for six tokens and wrong for thousands —
the payload grows with the registry, the batcher downloads prices for colours
no offer will ever mention, and a token minted since the last poll is
invisible until the next one.

`PriceLookup` replaces it. `validateInput` collects the offer's leg colours
and asks `GET /v1/prices?tokens=` for exactly those, in ONE request; answers
are cached per colour for `BATCHER_PRICE_TTL_MS` (default 10 min), so a busy
pair costs one request per TTL rather than one per offer, and an unfamiliar
colour is answered now instead of at the next poll. `sponsor_discount` and
the node's feed status are refreshed from every response.

A colour the node returns nothing for is cached as an ANSWER ("no price
here"), not as a gap — otherwise every offer on a test token would re-ask.
That keeps the two questions the policy already separated genuinely
separate: "the node has no market price for this" is
BATCHER_SPONSOR_UNPRICED's, "I could not ask" is BATCHER_SPONSOR_POLICY's.

Failure behaviour is unchanged in spirit and sharper in detail. A failed
re-ask does not erase what is cached: entries keep their original timestamp,
are still served while they are inside `BATCHER_PRICE_MAX_AGE_MS`, and only
then does the colour become unavailable (enforce → PRICE_UNAVAILABLE with
the colours named, warn → sponsor + log once a minute, off → skip). So a
brief node restart no longer makes every offer unavailable.

`BATCHER_PRICE_REFRESH_MS` is dropped with the poll it configured — setting
it is inert rather than an error, so a stale compose file does not block a
deploy. `BATCHER_PRICE_MAX_AGE_MS` keeps its name and gains a startup check
that it is >= the TTL: the reverse would strand colours that have a
perfectly recent answer in hand.

One optional request survives at startup — the NIGHT colour, seeded on every
network — so an operator can still read "am I price-aware?" off the startup
log. It is fire-and-forget and non-fatal.

Verified live against the real node API and the real batcher binary on
random free ports (Celestia deliberately unreachable, torn down after): one
probe at startup and nothing else; at reference the node answers 422 and the
batcher refuses with zero Celestia submissions, having asked only for the
want leg (the give leg was already cached); the same offer again asks the
node nothing; 2.5% below reference the offer queues.
@acedward

acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Q-11 scale amendment: the batcher looks prices up per offer

Head is now 89cfc3e (was be29306), base 00005-token-price-service-a-feed @ e2c7a63 (which carries the required ?tokens= parameter — see #54's comment).

Why: PriceCache polled the node's whole GET /v1/prices every ten minutes and held a snapshot. Fine for six tokens, wrong for thousands — the payload grows with the registry, the batcher downloads prices for colours no offer will ever mention, and a token minted since the last poll stays invisible until the next one.

PriceCachePriceLookup

validateInput collects the offer's leg colours and asks GET /v1/prices?tokens= for exactly those, in ONE request. Answers are cached per colour for BATCHER_PRICE_TTL_MS (default 10 min), so a busy pair costs one request per TTL rather than one per offer, and an unfamiliar colour is answered now instead of at the next poll. sponsor_discount and the node's feed block are refreshed from every response. (git mv, so the file history follows.)

A colour the node returns nothing for is cached as an ANSWER, not as a gap. Without that, every offer on a faucet-minted test token would re-ask forever — the exact per-offer cost this change exists to remove. It also keeps the two questions the policy already separated genuinely separate:

  • unpriced — "the node answered and has no market price for this colour" → BATCHER_SPONSOR_UNPRICED
  • unavailable — "I could not ask" → BATCHER_SPONSOR_POLICY

Failure behaviour: same in spirit, sharper in detail

A failed re-ask does not erase what is cached. Entries keep their original timestamp, are still served while they are inside BATCHER_PRICE_MAX_AGE_MS, and only then does the colour become unavailable (enforcePRICE_UNAVAILABLE naming the colours, warn → sponsor + log once a minute, off → skip). So a brief node restart no longer makes every offer unavailable.

Env

  • BATCHER_PRICE_REFRESH_MS is dropped with the poll it configured. Setting it is inert rather than an error — an operator upgrading a compose file should not be blocked by a leftover variable — and a test asserts it changes nothing so it cannot silently appear to work.
  • BATCHER_PRICE_TTL_MS (default 600000) is new.
  • BATCHER_PRICE_MAX_AGE_MS keeps its name and meaning restated: how stale an answer may be when a refresh fails. It now throws at startup if it is below the TTL — the reverse means "re-ask after 10 minutes but refuse anything older than 5", which would strand colours whose perfectly recent answer is in hand.

One optional request survives at startup — the NIGHT colour, seeded on every network — so an operator can still read "am I price-aware?" off the startup line. Fire-and-forget and non-fatal.

Gates

Check be29306 89cfc3e
bun test packages/database packages/node packages/offer-guard packages/price-feed packages/batcher packages/validator 542 pass / 2 skip / 0 fail 572 pass / 2 skip / 0 fail, 3909 expects, 54 files
bun run typecheck (3 gates) 0 / 0 / 0 0 / 0 / 0
bun run check:pgtypes green green (no SQL here)

+30 = +15 inherited from the rebased base and +15 here: price-lookup.test.ts 25 → 35, sponsorship-gate.test.ts 21 → 26.

Live check — the request shape, measured

Real node API + the real batcher binary over real sockets, random free ports (15939 PGlite / 56934 node / 47807-8 batcher), Celestia deliberately unreachable, torn down after (ports free, storage dir removed, git status clean, no docker artifacts). The harness added one fastify onRequest hook of its own to record every request the batcher made.

Startup — one probe and nothing else:

[zswap-da-batcher] prices: asked for 1 color(s), 1 priced, discount 2.50% from http://127.0.0.1:56934/v1/prices
[zswap-da-batcher] sponsorship: policy=enforce unpriced=allow prices=1 color(s) cached, last answer 0s ago, ttl=600s max_age=172800s discount=2.50% node=…
node requests during startup: ["GET /v1/prices?tokens=0000…0000"]

At reference, enforce on both sides:

POST /v1/offers  → 422 {"error":"NOT_SPONSORED","reason":"wants 0.0% below reference, sponsorship needs ≥ 2.5% below","give_usd":19181.81,"want_usd":19181.81,"implied_discount":0,"sponsor_discount":0.025}
POST /send-input → 400 {"success":false,"error":"Validation failed","message":"NOT_SPONSORED: wants 0.0% below reference, … (give_usd 19181.81, want_usd 19181.81)"}
node requests for this offer: ["POST /v1/offers","GET /v1/prices?tokens=ffff…ffff"]

That is the amendment, live: one lookup, naming only the want leg — the give leg was already cached from the startup probe.

The same offer again: node requests: [].

2.5% below reference (fresh batcher, empty cache): 200 {"success":true,"message":"Input queued for batching","inputsProcessed":1}, 🔗 Processing 1 inputs for target: celestia, again exactly one ?tokens=ffff… lookup. Zero Celestia submissions across both phases — the DA transport was the only thing denied.

Every node request the batcher made, in order — GET /v1/prices never appears without ?tokens=:

GET /v1/prices?tokens=0000…0000   ← startup probe
GET /v1/prices?tokens=ffff…ffff   ← at reference, the want leg
GET /v1/prices?tokens=0000…0000   ← fresh process, probe again
GET /v1/prices?tokens=ffff…ffff   ← 2.5% below, the want leg

Two honest gaps in the harness, neither a gate finding: the 2.5%-below POST /v1/offers answered 500 relation "effectstream.effectstream_blocks" does not exist — the framework's own block store, which bare migrationTable does not create; the node's pre-check passed there (no 422, unlike at reference), and the batcher path was driven directly through /send-input. And implied_discount reads 0 where B4 recorded 2.22e-16, because this harness computes the exact at-reference price instead of a rounded literal.

Still unchanged: the shipped defaults (warn + allow), the 422 contract, and the pinned note to retarget this PR to main once #54 merges.

@acedward
acedward merged commit 0712ce8 into 00005-token-price-service-a-feed Sep 3, 2026
5 checks passed
acedward added a commit that referenced this pull request Sep 3, 2026
…feed

feat(batcher): sponsorship gate — pay the Celestia fee only for offers priced below reference (re-land of #55 onto main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant