Skip to content

feat(oracle): publish Lens aggregates through a SEP-40 on-chain feed (#222) - #579

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
Neziahtech:feat/sep40-oracle-publisher-222
Oct 22, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
Neziahtech:feat/sep40-oracle-publisher-222

Conversation

@Neziahtech

Copy link
Copy Markdown
Contributor

Summary

Lens has only ever read oracle prices, so a Soroban contract that wants a Lens number has nothing to read. This PR adds both halves: a SEP-40 feed contract at oracle/contract/ implementing the consumer interface a contract can already code against (base, assets, decimals, resolution, price, prices, lastprice), and a publisher loop at src/publish/ that keeps it current. The contract's report() entry point only accepts the configured publisher's signature, and every price carries the venue names behind it, so the stored source_count is derived from provenance rather than asserted. The publisher's policy is a pure function over the aggregate, the value already on chain, and the clock — it refuses a stale or unsourceable aggregate before it reads the chain at all, and it writes on deviation or a maximum interval, rate-limited by a minimum write interval, never once per sample. The feed's base, decimals and resolution are read from the deployed contract and the cycle fails closed if those reads fail, because a price published at the wrong scale is not slightly wrong, it is off by orders of magnitude and every downstream consumer trusts it because it came from a chain. Publication is off by default on every network, no key is committed, and no REST or GraphQL route is added.

Closes #222

Acceptance criteria

A documented SEP-40 compatible surface a contract can read — docs/sep40-oracle-publisher.md §1 documents the read surface, the real_price = price / 10^decimals rule, SEP-40's timestamp trimming (floor(unix_now() / resolution) * resolution), and None-on-invalid-input instead of an error. §6 gives a Rust consumer snippet, §7 a verified JS one. The address is stable across upgrades (upgrade keeps the contract id), which is what lets a consumer hard-code it.

Publication triggers on deviation and a maximum interval, not on every sample — src/publish/trigger.ts. Triggers are first_report, deviation (default 0.5%, measured against lastprice() from the contract rather than a local variable, so two publisher instances agree on what "moved" means) and max_interval (default 15 min). Two vetoes can still stop a trigger that fired: tick_not_advanced and min_write_interval (default 60 s), each recording suppressedBy so an audit row distinguishes "the market did not move" from "it moved but we are rate-limited". At the defaults, an hour of 30-second polling that moves 0.5% every tick is 60 writes maximum, not 120.

A stale aggregate is never published — evaluateGuards() runs before lastprice is ever called, so a blocked tick provably touches no RPC and spends no fee. The guards are no_venue, invalid_price, no_observation, stale_aggregate (the same flag /price reports, so the feed can never carry a price the API calls stale) and aggregate_too_old (SEP40_MAX_AGE_SECONDS, default 120 s — deliberately stricter than the API's 300-second stale flag, because an HTTP price can be "as good as we have it" and a price a contract settles against cannot). sep40Publisher.test.ts asserts zero lastPrice/report calls for a stale aggregate.

Deployment and key handling are documented; no key is committed — §4 covers build, deploy, initialize, set_publisher, the upgrade sequence and the publisher's env vars; §5 covers the admin-cold / publisher-hot split, funding math, rotation and failing closed. .env.example carries the variable names with empty values and comments only. No secret key appears in source, docs, tests or changesets, and the key is only ever read through Keypair.fromSecret() inside src/publish/onchain.ts — never logged, never returned, never stored on an audit row.

How a Lens pair becomes a feed value

SEP-40 quotes every asset against one base; Lens stores a pair as price = assetB per assetA. The base decides which leg gets published and whether the number has to be inverted: base equals assetB → quote assetA directly; base equals assetA → invert and quote assetB; neither → skip with base_not_in_pair, because publishing it would put a ratio under the wrong label. Worked example: a stored 0.05 USDC per XLM on a feed whose base is XLM publishes as 20 (XLM per USDC), at 14 decimals 2000000000000000, filed under the USDC asset, not under "USDC".

Scaling goes through a fixed-point string, not price * 10 ** decimals: that product is not even an integer for ordinary prices (0.29 * 1e14 is 28999999999999.996), so the drift would be invisible and would land on chain where every consumer trusts it.

Provenance

Every published number names the venues behind it: SELECT DISTINCT source FROM price_points WHERE pair_key = $1 AND network = $2 AND timestamp > NOW() - $3, one-hour window, with network in the WHERE clause rather than implied — a provenance list that leaked mainnet venues onto a testnet report is exactly the silent blending the issue calls out. An empty venue list blocks with no_venue; a name the contract cannot store blocks with venue_provenance instead of burning a doomed transaction. Nothing is blended across venues. Every attempt, published or failed, is recorded in the new oracle_publications table with trigger, deviationPct, venues, scaledPrice, txHash and errorMessage.

Network handling

The publisher starts once per entry in ENABLED_NETWORKS inside restartIngester, so a crash restarts that network's publisher after 10 s and cannot affect the other network or the other adapters. It uses getNetworkConfig(network).pairs, not the shared runtime registry, and every aggregate, venue and price_points read carries the network it is publishing to. It cannot merge mainnet and testnet into one series.

Tests

  • cd oracle/contract && cargo test --lib → 20 passed, including that a write without the publisher's authorization does not land (env.set_auths(&[]) disables the SDK's auth mocking and try_report returns Some(Err(InvokeError::Abort))), that the signer named by env.auths() is the stored publisher, that a timestamp ahead of ledger time is refused, that history stays bounded and newest-first, and that prices(asset, 0) is invalid input returning None rather than an error. cargo build --release --target wasm32v1-none produces lens_sep40_oracle.wasm (29,728 bytes) with zero warnings.
  • npm test -- src/__tests__/sep40Encoding.test.ts src/__tests__/sep40Trigger.test.ts src/__tests__/sep40Publisher.test.ts (this repo's tests are vitest, not jest) → exact fixed-point scaling, i128 bounds and refusals, SAC address derivation, the {tag, values} and Vec[Symbol, value] wire forms, every guard outranking every trigger, an hour-long simulated poll asserting the write count is rate-limited rather than proportional to samples, and the whole cycle against recording fakes including "the chain was never touched".
  • npx tsc --noEmit → 0 errors (after prisma generate, which npm run build already runs; the new OraclePublication model has to be in the client).

Full suite on the rebased branch: 88 files, 83 passed, 1 skipped, 4 failed. Three of those four are CPU starvation from running the whole suite at once and pass standalone — fetchTimeout (asserted 3102 ms against a 2000 ms deadline), snapshot ("Test timed out in 20000ms"), graphqlQueryDepth ("Hook timed out in 10000ms" in beforeEach) — npx vitest run on just those three files is 28 passed, 0 failed.

The fourth is graphqlSchemaDrift.test.ts, and it is pre-existing on main, not caused by this branch: src/api/graphql.ts last changed today in 55ad93b (#360, the query-depth limit) while schema.graphql was last regenerated by 994fb76 (#476) on 2026-10-18, so the committed artifact is one PR behind the code. Neither file, nor scripts/generate-graphql-schema.ts, appears in this PR's diff. It is also not a line-ending problem — the working copy and the committed blob both contain zero CR bytes, and .gitattributes already forces *.graphql text eol=lf. I have not regenerated the artifact here because it belongs to whoever changed the schema.

Known limits

One feed, one base — pairs that do not contain the base are skipped, and quoting more bases means deploying another feed. price(asset, timestamp) answers from the retained 64 records; older history stays in Postgres behind /price/:a/:b/history.

There is no Lens feed contract id yet. The address only exists once someone deploys the WASM, so SEP40_CONTRACT_ID_TESTNET / _MAINNET stay empty and publication stays off until an operator deploys and funds a publisher. Per that rule I have not written a contract id anywhere in this PR; the only addresses in the diff are the two I could verify, and sep40Encoding.test.ts derives them rather than trusting them: new Asset('USDC', GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN).contractId(Networks.PUBLIC) → CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75, and the testnet issuer used in fixtures is GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5.

ContractSep40Feed — the RPC layer — is not exercised against a deployed feed by any test in this PR, because there is no deployed Lens feed to point at. What is proven here is the policy around it, the encoding both sides must agree on, and the contract itself. Pointing it at a real deployment is the remaining step and it needs the deploy first.

…ivinity-Labs#222)

Lens has only ever consumed oracle prices, so a Soroban contract that wants
a Lens number has nothing to read. This adds both halves: a SEP-40 feed
contract and the publisher loop that keeps it current.

oracle/contract implements the consumer interface a contract can already
code against -- base/assets/decimals/resolution/price/prices/lastprice --
with a report() entry point that only the configured publisher can sign.
Prices carry their venue list and the contract derives source_count from it,
so a published number names where it came from. 20 cargo tests.

src/publish is the publisher. The policy is a pure function over the
aggregate, the value already on chain, and the clock: it refuses a stale or
unsourceable aggregate before it reads the chain at all, and it writes on
deviation or a maximum interval, rate-limited by a minimum write interval --
never once per sample. Every attempt, published or failed, lands in
oracle_publications with the venues behind it.

The feed's base/decimals/resolution are read from the deployed contract and
the loop fails closed if those reads fail, because a price published at the
wrong scale is off by orders of magnitude and every consumer downstream
trusts it because it came from a chain.

Publication is off by default on every network, no key is committed, and no
REST or GraphQL route is added. Deploy and key handling are documented in
docs/sep40-oracle-publisher.md.
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@Neziahtech Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Miracle656 added a commit that referenced this pull request Oct 22, 2026
  tsc --noEmit   clean
  vitest         106 files, 1232 passing (up from 102 / 1157)
  dropped-tests  none

Four of the eight screened in this group. The other four were held, and the
reason is worth stating because it keeps recurring: each one merges cleanly
and fails on its own.

  #590  72 type errors alone, including syntax errors in src/api/rest.ts and
        src/api/schemas.ts -- "Declaration or statement expected",
        "Expression expected". Not type mismatches; the file does not parse.
  #584  3 type errors alone
  #592  conflicts
  #595  conflicts

Merged together before screening, that group produced 68 failing test files
and syntax errors I first mistook for a bad three-way merge. They were not:
#590 carries them on its own branch. Screening each PR individually before
batching costs one typecheck per PR and saves an hour of bisection, which is
the lesson of the day.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656
Miracle656 merged commit 243c3f4 into Divinity-Labs:main Oct 22, 2026
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.

Lens consumes oracle prices and publishes none

2 participants