Repository navigation
feat(oracle): publish Lens aggregates through a SEP-40 on-chain feed (#222) - #579
Merged
Miracle656 merged 1 commit intoOct 22, 2026
Conversation
…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.
|
@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! 🚀 |
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
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.
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 atsrc/publish/that keeps it current. The contract'sreport()entry point only accepts the configured publisher's signature, and every price carries the venue names behind it, so the storedsource_countis 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'sbase,decimalsandresolutionare 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, thereal_price = price / 10^decimalsrule, SEP-40's timestamp trimming (floor(unix_now() / resolution) * resolution), andNone-on-invalid-input instead of an error. §6 gives a Rust consumer snippet, §7 a verified JS one. The address is stable across upgrades (upgradekeeps 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 arefirst_report,deviation(default 0.5%, measured againstlastprice()from the contract rather than a local variable, so two publisher instances agree on what "moved" means) andmax_interval(default 15 min). Two vetoes can still stop a trigger that fired:tick_not_advancedandmin_write_interval(default 60 s), each recordingsuppressedByso 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 beforelastpriceis ever called, so a blocked tick provably touches no RPC and spends no fee. The guards areno_venue,invalid_price,no_observation,stale_aggregate(the same flag/pricereports, so the feed can never carry a price the API calls stale) andaggregate_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.tsasserts zerolastPrice/reportcalls 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.examplecarries 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 throughKeypair.fromSecret()insidesrc/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 asprice = 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 withbase_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 decimals2000000000000000, 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 * 1e14is28999999999999.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, withnetworkin 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 withno_venue; a name the contract cannot store blocks withvenue_provenanceinstead of burning a doomed transaction. Nothing is blended across venues. Every attempt, published or failed, is recorded in the neworacle_publicationstable withtrigger,deviationPct,venues,scaledPrice,txHashanderrorMessage.Network handling
The publisher starts once per entry in
ENABLED_NETWORKSinsiderestartIngester, so a crash restarts that network's publisher after 10 s and cannot affect the other network or the other adapters. It usesgetNetworkConfig(network).pairs, not the shared runtime registry, and every aggregate, venue andprice_pointsread 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 andtry_reportreturnsSome(Err(InvokeError::Abort))), that the signer named byenv.auths()is the stored publisher, that a timestamp ahead of ledger time is refused, that history stays bounded and newest-first, and thatprices(asset, 0)is invalid input returningNonerather than an error.cargo build --release --target wasm32v1-noneproduceslens_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,i128bounds and refusals, SAC address derivation, the{tag, values}andVec[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 (afterprisma generate, whichnpm run buildalready runs; the newOraclePublicationmodel 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" inbeforeEach) —npx vitest runon just those three files is 28 passed, 0 failed.The fourth is
graphqlSchemaDrift.test.ts, and it is pre-existing onmain, not caused by this branch:src/api/graphql.tslast changed today in55ad93b(#360, the query-depth limit) whileschema.graphqlwas last regenerated by994fb76(#476) on 2026-10-18, so the committed artifact is one PR behind the code. Neither file, norscripts/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.gitattributesalready 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/_MAINNETstay 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, andsep40Encoding.test.tsderives them rather than trusting them:new Asset('USDC', GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN).contractId(Networks.PUBLIC)→CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75, and the testnet issuer used in fixtures isGBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5.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.