Skip to content

feat(batcher): sponsorship gate — pay the Celestia fee only for offers priced below reference (re-land of #55 onto main) - #56

Merged
acedward merged 6 commits into
mainfrom
00005-token-price-service-a-feed
Sep 3, 2026
Merged

feat(batcher): sponsorship gate — pay the Celestia fee only for offers priced below reference (re-land of #55 onto main)#56
acedward merged 6 commits into
mainfrom
00005-token-price-service-a-feed

Conversation

@acedward

@acedward acedward commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Re-land of #55 onto main. #55 was merged 10 s after #54 with its base still set to the A branch, so its commits (bb2f0e589cfc3e) landed on 00005-token-price-service-a-feed @ 0712ce8, not on main. main @ dcf1ef7 has the price feed (#54) but not the batcher gate. This PR is that branch against main: 25 files, exactly #55's diff, nothing else — verified with git diff origin/main..0712ce8. Pre-merge full-stack verification (grand e2e 246 checks, SC-002 with Celestia under both policies, frontend walkthrough) was run on this exact tree (89cfc3e), see project 00005 P5a.

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 and others added 6 commits September 2, 2026 19:18
…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.
…batcher-gate

feat(sponsorship): pay the Celestia fee only for offers priced below reference
@acedward
acedward merged commit f92c7ca into main Sep 3, 2026
5 checks passed
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