From 83d9bd7c1446065553ea372de84ef51e43484c4d Mon Sep 17 00:00:00 2001 From: NeziahTech Date: Mon, 19 Oct 2026 04:23:18 +0100 Subject: [PATCH] feat(oracle): publish Lens aggregates through a SEP-40 on-chain feed (#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. --- .changeset/sep40-oracle-publisher.md | 45 + .env.example | 48 + .gitignore | 4 + CONTRIBUTING.md | 3 + README.md | 1 + docs/sep40-oracle-publisher.md | 513 +++++++ oracle/contract/Cargo.lock | 1881 ++++++++++++++++++++++++++ oracle/contract/Cargo.toml | 25 + oracle/contract/README.md | 18 + oracle/contract/src/lib.rs | 844 ++++++++++++ prisma/schema.prisma | 68 + src/__tests__/sep40Encoding.test.ts | 222 +++ src/__tests__/sep40Publisher.test.ts | 303 +++++ src/__tests__/sep40Trigger.test.ts | 250 ++++ src/config.ts | 104 ++ src/index.ts | 14 +- src/publish/onchain.ts | 317 +++++ src/publish/publisher.ts | 459 +++++++ src/publish/sep40.ts | 279 ++++ src/publish/trigger.ts | 260 ++++ 20 files changed, 5657 insertions(+), 1 deletion(-) create mode 100644 .changeset/sep40-oracle-publisher.md create mode 100644 docs/sep40-oracle-publisher.md create mode 100644 oracle/contract/Cargo.lock create mode 100644 oracle/contract/Cargo.toml create mode 100644 oracle/contract/README.md create mode 100644 oracle/contract/src/lib.rs create mode 100644 src/__tests__/sep40Encoding.test.ts create mode 100644 src/__tests__/sep40Publisher.test.ts create mode 100644 src/__tests__/sep40Trigger.test.ts create mode 100644 src/publish/onchain.ts create mode 100644 src/publish/publisher.ts create mode 100644 src/publish/sep40.ts create mode 100644 src/publish/trigger.ts diff --git a/.changeset/sep40-oracle-publisher.md b/.changeset/sep40-oracle-publisher.md new file mode 100644 index 00000000..a095d0bf --- /dev/null +++ b/.changeset/sep40-oracle-publisher.md @@ -0,0 +1,45 @@ +--- +'lens': minor +--- + +Lens publishes prices. A SEP-40 (`oracle/contract/`) oracle contract plus a +rate-limited, deviation-triggered publisher (`src/publish/`) put Lens's own +aggregate 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 `PriceFeedTrait` verbatim — `base`, `assets`, +`decimals`, `resolution`, `price`, `prices`, `lastprice` — returning `None` +rather than reverting on input it cannot answer, and adds one provider-side +write: `report(asset, price, timestamp, venues)`, authorized solely by the +publisher 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()`, is +not ahead of ledger time, and is strictly newer than the last record, and the +venue list is non-empty — `source_count` is derived from that list, never passed +in. `last_report` exposes the venues, the ledger and the close time behind the +current price. `initialize` is one-shot and `upgrade` swaps the WASM without +changing 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 by `restartIngester`, each reading only +its own network's pairs and rows. Which leg of a pair it quotes and whether the +stored `price = assetB per assetA` has to be inverted are derived from the +`base()` the *deployed contract* reports — the scale and tick come from the +chain 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_PCT` move measured against +the price already on chain or a `SEP40_MAX_INTERVAL_MS` timer, and even then +`SEP40_MIN_WRITE_INTERVAL_MS` caps it at one transaction per asset per interval. +`SEP40_MAX_AGE_SECONDS` defaults to 120, stricter than the API's 300 s stale +flag on purpose. + +Every write — and every failed write — lands in a new `oracle_publications` +table 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 deployed +contract 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. diff --git a/.env.example b/.env.example index a23c6be6..1ce472f0 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,19 @@ AQUARIUS_ENABLED_TESTNET=false # Reflector oracle contract (56-char C... id) — see https://reflector.network. Empty = oracle disabled. # A malformed id disables the oracle on this network and logs a warning naming this variable. REFLECTOR_CONTRACT_ID_TESTNET= +# SEP-40 publisher (#222): the oracle contract Lens pushes ITS aggregate into, +# so Soroban contracts can read a Lens price. Deploy it from oracle/contract/ +# and paste the id `soroban contract deploy` prints — never a placeholder. +SEP40_CONTRACT_ID_TESTNET= +# Secret key of the account the contract lists as publisher. It pays the write +# fee, so it must be funded on testnet. Never commit a real key; docs/ +# sep40-oracle-publisher.md covers funding, separation from the admin key and +# rotation. Publication stays off while this is unset. +SEP40_PUBLISHER_SECRET_KEY_TESTNET= +# Off by default: publishing spends fees and writes to a chain, so it is +# opt-in per network. Setting this to true without the two values above logs a +# warning and leaves publication off rather than half-working. +SEP40_ENABLED_TESTNET=false # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. WATCHED_PAIRS_TESTNET=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 @@ -97,6 +110,12 @@ AQUARIUS_ENABLED_MAINNET=true # Reflector oracle contract on mainnet (56-char C... id) — copy it from https://reflector.network. # There is no built-in default: leave empty to keep the oracle disabled. REFLECTOR_CONTRACT_ID_MAINNET= +# SEP-40 publisher on mainnet. Same three variables as testnet above; mainnet is +# additionally gated on a funded publisher account and a real RPC provider +# (RPC_URL_MAINNET), so read docs/sep40-oracle-publisher.md before enabling it. +SEP40_CONTRACT_ID_MAINNET= +SEP40_PUBLISHER_SECRET_KEY_MAINNET= +SEP40_ENABLED_MAINNET=false # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN @@ -117,6 +136,35 @@ SOROSWAP_FACTORY_ADDRESS=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTB WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 # Reflector oracle contract ID on testnet (falls back from paired REFLECTOR_CONTRACT_ID_TESTNET) REFLECTOR_CONTRACT_ID= +# SEP-40 publisher fallbacks on testnet (fall back from the paired _TESTNET vars above) +SEP40_CONTRACT_ID= +SEP40_PUBLISHER_SECRET_KEY= + +# --- SEP-40 Oracle Publisher (issue #222) --- +# WHEN Lens writes its aggregate to the contract; the contract/keys per network +# are configured above. The feed is deviation-triggered with a heartbeat, not a +# write per tick — writing every sample is what makes an on-chain oracle +# unaffordable, and these are the knobs that decide the difference. +# Publish once the aggregate has moved at least this many percent away from the +# value already on chain (default 0.5). +SEP40_DEVIATION_PCT=0.5 +# …and publish at least once per this many milliseconds even if it has not moved +# (default 900000 = 15 min), so a quiet market does not leave consumers reading +# a timestamp that only gets older. +SEP40_MAX_INTERVAL_MS=900000 +# Hard floor between two writes for the same asset, whatever triggered them +# (default 60000). This is the rate limit: it caps the transaction count in a +# fast market at one write per asset per interval, not one per sample. +SEP40_MIN_WRITE_INTERVAL_MS=60000 +# Never publish an aggregate whose newest contributing observation is older than +# this many seconds (default 120). Stricter than the API's own 300 s stale flag +# on purpose: an HTTP price can be "as good as we have it", a price a contract +# settles against cannot. See docs/sep40-oracle-publisher.md. +SEP40_MAX_AGE_SECONDS=120 +# How often the publisher evaluates the triggers above (default 30000). Shorter +# means a deviation is caught sooner; it does NOT mean more writes, because +# SEP40_MIN_WRITE_INTERVAL_MS bounds those. +SEP40_POLL_INTERVAL_MS=30000 # --- Indexer Configuration --- # Frequency of polling for new trades/snapshots in milliseconds (default: 5000) diff --git a/.gitignore b/.gitignore index 53c49979..d020720d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ dist/ .env *.tsbuildinfo + +# Soroban build artifacts for the SEP-40 oracle contract (oracle/contract) +oracle/contract/target/ +oracle/contract/test_snapshots/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac301046..90107695 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,10 +21,13 @@ lens/ │ ├── routes/ # Additional REST route modules │ ├── ingesters/ # SDEX + AMM ingester loops │ ├── ingest/ # Venue/oracle adapters (Horizon AMM, Aquarius, Reflector) +│ ├── publish/ # SEP-40 publisher: encoding, publication policy, on-chain feed │ ├── aggregator/ # VWAP + best-route calculation │ ├── pricing/ # TWAP, depth and aggregate helpers │ ├── middleware/ # Network selector, x402, auth, metrics │ └── x402/ # x402 facilitator client + metering +├── oracle/ +│ └── contract/ # Soroban SEP-40 price feed contract (Rust, `cargo test --lib`) ├── prisma/ │ └── schema.prisma # Network-discriminated data models ├── tests/ # Integration, load and property suites diff --git a/README.md b/README.md index 0b6cdb7c..20a0c429 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,7 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). +Lens also *publishes* prices: [`docs/sep40-oracle-publisher.md`](docs/sep40-oracle-publisher.md) covers the SEP-40 contract in `oracle/contract/`, the deviation-and-interval publication policy in `src/publish/`, deployment, and key handling. The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. Operational docs: [HTTP metrics](docs/http-metrics.md), [ingest metrics](docs/ingest-metrics.md), [websocket backpressure](docs/websocket-backpressure.md), [health probes](docs/health.md), [GraphQL](docs/graphql.md). diff --git a/docs/sep40-oracle-publisher.md b/docs/sep40-oracle-publisher.md new file mode 100644 index 00000000..98e45acc --- /dev/null +++ b/docs/sep40-oracle-publisher.md @@ -0,0 +1,513 @@ +# SEP-40 oracle publisher + +Lens has always been an oracle **consumer**: it reads Reflector's prices and +compares them with its own. Issue #222 is the other direction — Lens as an +oracle **publisher**, exposing a [SEP-40](https://github.com/StellarCommunity/seps/blob/main/SEP-0040.md) +compatible price feed that any Soroban contract can read. + +Two pieces make up the feature: + +| Piece | Where | What it does | +| --- | --- | --- | +| The feed | `oracle/contract/` | A Soroban contract implementing SEP-40's `PriceFeedTrait`, plus one provider-side write (`report`). | +| The publisher | `src/publish/` | The process that decides **when** a Lens aggregate is worth a transaction, and writes it. Runs once per enabled network, next to the ingest loops. | + +SEP-40 standardises the *read* side only ("the exact mechanism of data reporting +mechanism utilized by an oracle is out of scope of this SEP"), so `report` is +Lens's own design and every other entry point is exactly the shape the spec +defines. + +--- + +## 1. The contract + +`oracle/contract/src/lib.rs` — package `lens_sep40_oracle`. + +### 1.1 The SEP-40 read surface + +Declared names and signatures are the spec's, not adaptations: + +| Entry point | Signature | Lens's behaviour | +| --- | --- | --- | +| `base` | `() -> Asset` | The asset every price in the feed is denominated in. Set once at `initialize`. | +| `assets` | `() -> Vec` | Every asset that has ever been reported, in first-seen order. | +| `decimals` | `() -> u32` | Scale of `PriceData.price`. Immutable. | +| `resolution` | `() -> u32` | Tick period in seconds that timestamps are trimmed to. Immutable. | +| `price` | `(Asset, u64) -> Option` | The newest record **at or before** the requested timestamp. | +| `prices` | `(Asset, u32) -> Option>` | The last *n* records, newest first. | +| `lastprice` | `(Asset) -> Option` | The current feed value. | +| `last_report` | `(Asset) -> Option` | Lens-specific: provenance of the last write (see 1.4). | + +A deviation-triggered feed is deliberately non-uniform — there is no record at +every tick — so `price(asset, timestamp)` answers with the value that was **in +effect** at that time rather than looking for an exact match. That is the +extrapolation SEP-40 asks an oracle to present, and it is why the feed can be +consumed by contracts that assume a tick per resolution. + +### 1.2 Price encoding + +`PriceData.price` is an `i128` scaled by `decimals()`: + +``` +real_price = PriceData.price / 10^decimals() +``` + +`PriceData.timestamp` is unix **seconds**, and the contract enforces SEP-40's +trimming rule on every write: + +``` +timestamp == floor(unix_now() / resolution()) * resolution() +``` + +A write whose timestamp is not a whole number of ticks is rejected +(`TimestampNotAligned`), so a consumer can treat trimming as a guarantee instead +of a convention. Lens trims on the way out (`trimTimestamp` in +`src/publish/sep40.ts`) and the contract re-checks. + +Scaling is done through a decimal **string**, not a float multiply, because +`0.29 * 1e14` is `28999999999999.996` — `BigInt()` of that throws and +`Math.round()` of that puts `28999999999999` on chain. One decimal digit off is +a price that is wrong by a factor of ten, so `scalePrice` refuses non-integer +results, exponential notation, non-positive prices and values beyond `i128`. + +### 1.3 `None`, not an error + +SEP-40: "oracles should delegate error handling logic directly to consumer +contracts". So the read side returns `None` — never a revert — for an asset the +feed has never quoted, a timestamp before the first report, a `records` count of +zero, and an empty history. A consumer that wants to distinguish "no data" from +"bad request" has to ask for `last_report` or call `assets()`; a consumer that +just wants to not settle on missing data checks for `None`. + +### 1.4 Provenance, beyond SEP-40 + +SEP-40's `PriceData` has no room for "where did this come from", and Lens +refuses to publish a number it cannot source. Every `report` therefore carries +the venue names behind it: + +```rust +pub struct Report { + pub price: i128, + pub timestamp: u64, // SEP-40 feed timestamp + pub reported_at: u64, // ledger close time of the write + pub reported_ledger: u32, // ledger the write landed in + pub source_count: u32, // always venues.len() + pub venues: Vec, // 'SDEX', 'AMM', 'aquarius_amm', 'soroswap_amm', … +} +``` + +`source_count` is **derived** from the venue list rather than passed in, so the +contract cannot store a price claiming more sources than it can name. An empty +venue list is rejected (`NoVenues`), as is a list longer than `MAX_VENUES` (16). +Venues are never blended: the aggregate `price` is a route-aware best quote and +the list says which markets produced it, exactly as `/price/:a/:b` reports +`bestRoute` and `sources`. + +`reported_at` and `reported_ledger` are what let a consumer apply the same +staleness bar the HTTP API does: `lastprice.timestamp` says when the *market* +tick happened, `last_report.reported_at` says when the feed *learned* it. + +### 1.5 Errors + +| Code | Name | Raised when | +| --- | --- | --- | +| 1 | `AlreadyInitialized` | `initialize` is called a second time. | +| 2 | `NonPositivePrice` | `price <= 0`. | +| 3 | `TimestampNotAligned` | `timestamp % resolution != 0`. | +| 4 | `NotNewer` | `timestamp` is not strictly newer than the asset's last record. | +| 5 | `TimestampInFuture` | `timestamp > env.ledger().timestamp()`. | +| 6 | `NoVenues` | Empty venue list. | +| 7 | `TooManyVenues` | More than `MAX_VENUES` names. | +| 8 | `InvalidConfig` | `decimals == 0` or `resolution == 0` at `initialize`. | + +`NotNewer` and `TimestampInFuture` are what make a retried publish harmless: a +replay of a tick that already landed is refused, and a publisher with a clock +ahead of the ledger cannot hand consumers a price that never ages. + +### 1.6 Admin surface + +- `initialize(admin, base, decimals, resolution)` — one-shot, admin-authenticated. +- `set_publisher(publisher)` — admin-authenticated; points `report` at the hot key. +- `upgrade(new_wasm_hash)` — admin-authenticated; swaps the WASM **without + changing the contract address**, which SEP-40 requires ("smart contract + consumers should only be expected to change their price feed aggregator address + if they would like to change oracle providers"). + +`report` authorizes exactly one address: the publisher when one is set, the +admin otherwise. Tests assert both halves of that — that a write carrying no +publisher authorization does not land, and that the address the feed demands is +the publisher and not the transaction's signer. + +### 1.7 Storage + +Per asset: `Last` (one `PriceData`), `LastReport` (one `Report`) and `History` +(newest-first, trimmed to `MAX_HISTORY = 64` records). The asset registry is one +instance entry. Persistent entries extend their TTL on read and write +(`TTL_THRESHOLD = 5_000`, `TTL_BUMP = 40_000` ledgers), and instance storage is +extended at `initialize`, so an idle feed does not expire out from under its +consumers. + +--- + +## 2. How a Lens pair becomes a feed value + +Lens stores a pair as **`price` = units of assetB per assetA**. SEP-40 quotes +every asset against one **base**. `orientAgainstBase` in +`src/publish/publisher.ts` reconciles the two from the base the *deployed +contract* reports — never from an env var, because a mis-declared scale produces +a price that is wrong by orders of magnitude and still reads fine: + +| Feed `base` | Quoted asset | Price published | +| --- | --- | --- | +| equals `assetB` | `assetA` | the stored number, as-is | +| equals `assetA` | `assetB` | `1 / stored` (inverted) | +| neither | — | pair skipped, reason recorded | + +Worked example, the default pair in `.env.example`: `XLM:native / USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` +stores `price = USDC per XLM`. With `base = Other("USDC")`, `decimals = 14`, the +feed quotes `Asset::Other("XLM")` directly — `0.025` becomes `250000000000000`. +The same feed pointed at `base = Other("XLM")` would quote the USDC leg and +publish `1 / 0.025 = 40` as `400000000000000000000`. + +A pair whose legs are neither of them is **not** published. Putting a ratio under +the wrong asset label is worse than an empty feed, so the cycle records the +reason (`base_not_in_pair`) and moves on. + +Asset identity follows SEP-40: an anchored asset is identified by the address of +its **Stellar Asset Contract**, derived with +`new Asset(code, issuer).contractId(passphrase)` — issuer-qualified by +construction, so two tokens called USDC are two different assets. Native XLM has +no asset contract and uses the spec's `Other(Symbol)` form. Contract addresses +are derived from the issuer in `WATCHED_PAIRS_*`, never typed by hand. As one +reference point, mainnet USDC (`GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`) +derives to `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75`; the test +suite asserts that derivation so a typo in the issuer cannot pass silently. +**This document deliberately contains no Lens feed contract id** — it is created +when the operator deploys `oracle/contract/` (§4), and a placeholder in the docs +is how five deployments end up pointing at five different guesses. + +--- + +## 3. When Lens writes + +The policy is `src/publish/trigger.ts`, a pure function over +(aggregate, what is already on chain, thresholds, clock). It exists because a +tick is not an event: Lens polls every few seconds, and a publisher that wrote +per sample would be a transaction spam bot that also publishes the noise between +trades. + +Order matters, and it is what makes the acceptance criteria checkable: + +**Guards — evaluated first, before any chain read.** + +| Guard | Condition | +| --- | --- | +| `no_venue` | No venue name behind the number. "A price you cannot source is worse than no price." | +| `invalid_price` | Zero, negative or non-finite. | +| `no_observation` | No trade has ever been recorded for this pair **on this network**. | +| `stale_aggregate` | The aggregator's own `stale` flag is set — the same signal `/price` reports, so the feed can never carry a price the API calls stale. | +| `aggregate_too_old` | `lastTradeAgeSeconds > SEP40_MAX_AGE_SECONDS` (default 120). | + +`evaluateGuards` runs **before** `lastprice` is read. A stale aggregate needs no +comparison to decide anything — it is simply not publishable — so a blocked tick +provably touches no RPC and spends no fee. That is the "a stale aggregate is +never published" criterion as a code path, not as a promise. + +**Triggers — at least one must fire.** + +| Trigger | Condition | +| --- | --- | +| `first_report` | The feed holds nothing for this asset. | +| `deviation` | The aggregate is `SEP40_DEVIATION_PCT` (default 0.5%) away from the **on-chain** price. | +| `max_interval` | The on-chain price is older than `SEP40_MAX_INTERVAL_MS` (default 900000 = 15 min), even without moving. | + +`deviation` wins when both fire. The reference for deviation is +`lastprice()` from the contract rather than a local variable, so two publisher +instances (a restart racing itself, a canary) agree on what "moved" means. + +**Write vetoes — a trigger that fired still may not write.** + +| Veto | Condition | +| --- | --- | +| `tick_not_advanced` | The current feed tick is not newer than the last report — the contract would answer `NotNewer`, so Lens does not send a doomed transaction. | +| `min_write_interval` | Fewer than `SEP40_MIN_WRITE_INTERVAL_MS` (default 60000) seconds since the last report. | + +Both record `suppressedBy`, so the audit row says which trigger was silenced and +by what — the difference between "the market did not move" and "the market moved +but we are rate-limited". + +`max_interval` is what stops a quiet market from leaving consumers reading a +timestamp that only gets older; `min_write_interval` is the actual rate limit — +it caps a fast market at one write per asset per interval, not one per sample. +At the defaults, an hour of 30-second polling moves at least 0.5% every tick: +**60 writes maximum, not 120**. The deviation threshold is measured against the +value already on chain, so a slow drift publishes when it has accumulated, not +on every step. + +`SEP40_MAX_AGE_SECONDS` (120) is deliberately stricter than the API's 300-second +stale flag. An HTTP price can be "as good as we have it"; a price a contract +settles against cannot. + +--- + +## 4. Deploying the feed + +### 4.1 Build (this part is reproducible here) + +```bash +cd oracle/contract +cargo build --release --target wasm32v1-none +cargo test --lib # 20 tests, no network needed +``` + +The release profile in `Cargo.toml` is the one Soroban wants: `opt-level = "z"`, +`lto = true`, `panic = "abort"`. Output: +`target/wasm32v1-none/release/lens_sep40_oracle.wasm`. + +`Cargo.lock` is committed. It pins `ed25519-dalek` to 2.2.0 on purpose: +`soroban-env-host` 22.1.3 (which `soroban-sdk` 22.0.11 pins exactly) does not +compile against the 3.0.0 release, and the resolution is what makes +`cargo test --lib` work on a fresh clone. + +### 4.2 Deploy, initialize, hand over + +The `soroban` CLI is not part of this repository's CI, and its flag names and +enum-argument JSON have moved between versions — check `soroban --version` and +` --help` before pasting. The **sequence** is what matters, and each +step is verifiable: + +```bash +# 1. Deploy the WASM. The address it prints is your feed's SEP-40 address. +soroban contract deploy --source "$ADMIN_ACCOUNT" --network testnet \ + --wasm target/wasm32v1-none/release/lens_sep40_oracle.wasm + +# 2. Configure it once. base/decimals/resolution are immutable afterwards. +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ + initialize --admin "$ADMIN_ACCOUNT" --base "" \ + --decimals 14 --resolution 60 + +# 3. Point report at the hot publisher key (a different account from $ADMIN_ACCOUNT). +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ + set_publisher --publisher "$PUBLISHER_ACCOUNT" + +# 4. Read the parameters back before letting Lens near it. +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- base +soroban contract invoke --id "$FEED" --network testnet -- decimals +soroban contract invoke --id "$FEED" --network testnet -- resolution +``` + +Recommended values, matching the contract's test suite and the scaling in +`src/publish/sep40.ts`: `decimals = 14`, `resolution = 60`. Lens reads both from +the contract at runtime and **fails closed** if the reads fail — there is no +fallback scale. `base` should be the asset you want your consumers quoted in, +and it must be one leg of every pair you intend to publish (§2). + +Upgrades keep the address: + +```bash +soroban contract install --source "$ADMIN_ACCOUNT" --network testnet \ + --wasm target/wasm32v1-none/release/lens_sep40_oracle.wasm # prints the WASM hash +soroban contract invoke --id "$FEED" --source "$ADMIN_ACCOUNT" --network testnet -- \ + upgrade --new_wasm_hash "$WASM_HASH" +``` + +### 4.3 Run the publisher + +```bash +# .env (never committed) +ENABLED_NETWORKS=testnet +SEP40_ENABLED_TESTNET=true +SEP40_CONTRACT_ID_TESTNET= +SEP40_PUBLISHER_SECRET_KEY_TESTNET= +``` + +`src/index.ts` starts one publisher per enabled network the same way it starts +venue ingesters — inside `restartIngester`, so a crash restarts that network's +publisher after 10 s and cannot affect the other network or the other adapters. +Startup is quiet and safe by default: + +- `SEP40_ENABLED_*=false` (the default) → the loop never starts. +- Enabled but missing the contract id or the key → warning, publication stays off. +- A malformed (`not even a valid C…` id) → `isUsableContractId` warns and + disables it, as it does for every other contract address in `src/config.ts`. + +Pair lists come from `WATCHED_PAIRS_TESTNET` / `WATCHED_PAIRS_MAINNET` for that +network, not from the shared runtime registry, and every aggregate, venue and +`price_points` query in the loop carries `network` in its WHERE clause. A +publisher must not be able to quote testnet trades on a mainnet feed. + +No REST or GraphQL route is added: the point of SEP-40 is that a contract reads +the feed on chain, and the existing `/price` already serves the off-chain +consumer. + +### 4.4 Audit trail + +`oracle_publications` (Prisma model `OraclePublication`) records **writes**, not +every blocked decision — at the default 30-second poll a blocked decision would +be 2 880 rows a day per pair per network (5 760 on both, 17 280 for a three-pair +list) all saying "nothing happened". Columns: `network`, `pairKey`, +`quotedAsset`, `baseAsset`, `state` (`published` | `failed`), `trigger`, +`price`, `scaledPrice` (the `i128` as text), `decimals`, `feedTimestamp`, +`resolution`, `venues` (comma-joined names), `sourceCount`, `deviationPct`, +`reason`, `txHash`, `errorMessage`, `createdAt`. + +A failed write is recorded the same way a success is and the loop continues with +the next pair: the number Lens meant to publish, the venues behind it, and why it +did not land is exactly what an operator needs at 3 a.m. A feed Lens cannot +**read** is different — `FeedUnavailableError` aborts the cycle and the loop +backs off (10 s, doubling, capped at 60 s), because "we could not check what is +already on chain" means the deviation and monotonicity promises cannot be kept. + +`npm run db:push` (or your usual `prisma db push`) creates the table; this repo +has no migrations directory. + +--- + +## 5. Key handling + +Two accounts, and they are not interchangeable: + +| | Admin | Publisher | +| --- | --- | --- | +| Signs | `initialize`, `set_publisher`, `upgrade` | `report` — every publication | +| Lives | Cold: hardware wallet, or an offline signer. Not in any server's env if you can avoid it. | Hot: `SEP40_PUBLISHER_SECRET_KEY_*` on the host running Lens | +| Needs | Enough XLM for the rare admin transaction | XLM for one transaction per publication, per asset | +| Leak damage | Attacker can upgrade the feed — catastrophic | Attacker can publish bad prices until you rotate — bad, recoverable | + +- **Funding.** The publisher pays every write's fee, so it needs XLM on the same + network as the feed. At the defaults the worst case is bounded and + computable: one write per asset per `SEP40_MIN_WRITE_INTERVAL_MS`, so + `pairs × (86400 / minWriteIntervalSeconds)` transactions per day — 1 440 per + asset per day at the 60-second floor, and typically far fewer, because a + deviation has to occur for a write to be attempted at all. +- **Never committed.** `.env` is gitignored, `.env.example` carries the variable + names with empty values and comments only, and no secret key appears in source, + docs, tests, or changesets. The publisher only ever reads the key through + `Keypair.fromSecret()` inside `src/publish/onchain.ts`; it is not logged, not + returned, and not written to `oracle_publications`. +- **Rotation.** Fund a new account, `set_publisher` to it (admin signs), update + `SEP40_PUBLISHER_SECRET_KEY_*`, restart. Until the new key is live the old one + still authorizes writes; after `set_publisher` the old one does not — the + contract compares the stored publisher address, it does not trust the + transaction's signer. +- **Failing closed.** No key → no publication, with a log line saying so. A + partially configured publisher is worse than an absent one: it either burns + fees on the wrong feed or silently publishes nothing while looking healthy. +- If you use one Lens process for both networks, use one publisher account per + network. A leaked testnet key should not be a mainnet problem. + +--- + +## 6. Reading the feed from a contract + +A consumer declares the same two types and calls the spec's names — nothing +Lens-specific is required: + +```rust +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, IntoVal, Symbol}; + +#[contracttype] +#[derive(Clone)] +pub enum Asset { Stellar(Address), Other(Symbol) } + +#[contracttype] +#[derive(Clone)] +pub struct PriceData { pub price: i128, pub timestamp: u64 } + +#[contract] +pub struct Consumer; + +#[contractimpl] +impl Consumer { + /// XLM per USDC, or nothing at all if the feed has no price or the caller's + /// staleness bar is not met. + pub fn spot(env: Env, feed: Address, max_age: u64) -> Option { + let asset = Asset::Other(symbol_short!("XLM")); + let data: PriceData = env + .invoke_contract::>( + &feed, + &symbol_short!("lastprice"), + (asset,).into_val(&env), + ) + .ok()??; + if env.ledger().timestamp() - data.timestamp > max_age { + return None; // stale: refuse, rather than settle on an old tick + } + Some(data.price) // still scaled by the feed's decimals() + } +} +``` + +Check `None`, and check the age, before trusting a number: that is the whole +consumer-side contract, and it is why `decimals`/`resolution` are readable +entry points rather than documentation. + +## 7. Reading the feed from JS + +```ts +import { contract as StellarContract, Networks, SorobanRpc } from '@stellar/stellar-sdk' + +const client = await StellarContract.Client.from({ + contractId: process.env.SEP40_CONTRACT_ID_TESTNET!, + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, +}) + +// SEP-40's `Asset` is a `contracttype` enum with tuple variants, which the SDK +// represents natively as { tag, values } — the form `assetToSpecArg` builds. +const tx = await client.lastprice({ asset: { tag: 'Other', values: ['XLM'] } }) +if (!tx.result) throw new Error('the feed has no price for XLM yet') // None +const decimals = Number((await client.decimals()).result) // 14 +const price = Number(tx.result.price) / 10 ** decimals +const ageSeconds = Math.floor(Date.now() / 1000) - Number(tx.result.timestamp) +``` + +Awaiting a dynamic `Client` method simulates the call and gives an +`AssembledTransaction`; `.result` is the decoded return value. `None` from the +contract arrives as `null`/`undefined` here, which is the check §6 also demands. + +`src/publish/sep40.ts` has the encoders both directions (`assetToScVal`, +`assetFromSpecArg`, `scalePrice`, `unscalePrice`) if you need the XDR form, and +`src/publish/onchain.ts` is the working `Client.from({...})` setup the publisher +itself uses. Note the import path: this repo compiles with `module: commonjs`, so +the `@stellar/stellar-sdk/contract` subpath does not resolve and the `contract` +namespace comes from the package root. + +## 8. Tests + +```bash +npm test -- src/__tests__/sep40Encoding.test.ts \ + src/__tests__/sep40Trigger.test.ts \ + src/__tests__/sep40Publisher.test.ts +cd oracle/contract && cargo test --lib +``` + +This repo's tests are **vitest**, not jest. The three suites cover: + +- `sep40Encoding` — exact scaling (`0.29` at 14 decimals is the case that float + maths gets wrong), `i128` bounds, refusals, SAC-address derivation including + the mainnet USDC assertion and an imposter-issuer check, the `{tag, values}` + and `Vec[Symbol, value]` wire forms, venue list validation. +- `sep40Trigger` — every guard outranking every trigger (a stale aggregate that + has also aged out must not publish), both write vetoes with `suppressedBy`, + and an hour-long simulated poll asserting the write count is rate-limited + rather than proportional to samples. +- `sep40Publisher` — orientation and inversion, `base_not_in_pair`, provenance + audit rows, deviation measured against the on-chain value, a stale aggregate + proving **zero** `lastPrice`/`report` calls, failed writes recorded without + killing the cycle, and a bad issuer on one pair not silencing another. + +## 9. Known limits + +- One feed, one base. Pairs that do not contain the base are skipped with a + reason; quote more bases by deploying another feed. +- Publication is off by default on every network and needs a funded publisher; + nothing in this feature runs unless an operator opts in. +- `price(asset, timestamp)` answers from the retained 64 records. Older history + lives in Postgres and behind `/price/:a/:b/history`, not on chain. +- The RPC layer (`ContractSep40Feed`) is not exercised against a deployed feed + by any test in this PR — there is no Lens feed contract to point at yet. What + is proven here is the policy around it (`sep40Publisher`), the encoding both + sides must agree on (`sep40Encoding`, `sep40Trigger`), and the contract itself + (`cargo test --lib`). Signing up to a real network is the remaining step, and + it needs a deployment first. diff --git a/oracle/contract/Cargo.lock b/oracle/contract/Cargo.lock new file mode 100644 index 00000000..fdacc520 --- /dev/null +++ b/oracle/contract/Cargo.lock @@ -0,0 +1,1881 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes-lit" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lens_sep40_oracle" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" +dependencies = [ + "arbitrary", + "crate-git-revision", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30035cf1e8f02f65de3e594b6da113ecdaf1cd134d8480961d62568bb15adaf" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-sdk" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff18e8d7ca6d5340a211605ca2c86383bd4dfacc4f8253d72a1573974ffffe69" +dependencies = [ + "arbitrary", + "bytes-lit", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey", +] + +[[package]] +name = "soroban-sdk-macros" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b205cd86b34d530db87667bd287fbb194166d79b368227fd842110a914fde8" +dependencies = [ + "crate-git-revision", + "darling 0.20.11", + "itertools", + "proc-macro2", + "quote", + "rustc_version", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6a16f2de28852c759f4da5f28cda54ec0d8dfa4c0e6e8cb3495234a72b0cea" +dependencies = [ + "base64 0.13.1", + "stellar-xdr", + "thiserror 1.0.69", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6db5902ab21290dddf63fec4ee95703fe59891a947646e7b8607536f043fc" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" +dependencies = [ + "crate-git-revision", + "data-encoding", + "thiserror 1.0.69", +] + +[[package]] +name = "stellar-xdr" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" +dependencies = [ + "arbitrary", + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "serde", + "serde_with", + "stellar-strkey", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.2", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/oracle/contract/Cargo.toml b/oracle/contract/Cargo.toml new file mode 100644 index 00000000..949dde28 --- /dev/null +++ b/oracle/contract/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "lens_sep40_oracle" +version = "0.1.0" +edition = "2021" +description = "SEP-40 price feed contract that Lens publishes its aggregate into" +license = "MIT" + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +soroban-sdk = "22" + +[dev-dependencies] +soroban-sdk = { version = "22", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/oracle/contract/README.md b/oracle/contract/README.md new file mode 100644 index 00000000..d5b3bf9d --- /dev/null +++ b/oracle/contract/README.md @@ -0,0 +1,18 @@ +# SEP-40 oracle contract + +Lens's on-chain price feed. Implements the SEP-40 `PriceFeedTrait` read surface +(`base`, `assets`, `decimals`, `resolution`, `price`, `prices`, `lastprice`) plus +one provider-side write, `report`, which the Lens publisher in `src/publish/` is +the only caller of. + +```bash +cargo test --lib # 20 tests, no network needed +cargo build --release --target wasm32v1-none # target/wasm32v1-none/release/lens_sep40_oracle.wasm +``` + +Deploying, initializing, the publication policy and the key-handling rules are in +[`../../docs/sep40-oracle-publisher.md`](../../docs/sep40-oracle-publisher.md). + +There is no published contract id for this crate: the feed exists once an +operator deploys it, and the id they get is the one that goes in +`SEP40_CONTRACT_ID_TESTNET` / `SEP40_CONTRACT_ID_MAINNET`. diff --git a/oracle/contract/src/lib.rs b/oracle/contract/src/lib.rs new file mode 100644 index 00000000..79929406 --- /dev/null +++ b/oracle/contract/src/lib.rs @@ -0,0 +1,844 @@ +//! Lens SEP-40 price feed contract. +//! +//! SEP-40 ("Oracle Consumer Interface") standardises how a Soroban contract +//! READS a price feed. It deliberately says nothing about how a provider writes +//! one ("The exact mechanism of data reporting mechanism utilized by an oracle +//! is out of scope of this SEP" — SEP-40 § Design Rationale / Data Reporting). +//! So this contract has two halves: +//! +//! 1. the `PriceFeedTrait` surface from the spec — `base`, `assets`, `decimals`, +//! `resolution`, `price`, `prices`, `lastprice` — exactly as declared, so any +//! SEP-40 consumer can read a Lens price without an adapter contract; +//! 2. a provider-side `report` write, authenticated by the Lens publisher key, +//! which is the only way data gets in. +//! +//! Prices are stored as `i128` scaled by `decimals()`: the real value is +//! `price / 10^decimals` (SEP-40 § Design Rationale). Timestamps are unix +//! seconds trimmed to `resolution()`, so consumers can compare them against the +//! current ledger time to judge staleness — and Lens trims them on the way in, +//! which `report` then re-checks, so a mis-scaled write cannot land. +//! +//! Provenance: every report carries the list of venues behind the number +//! (`SDEX`, `AMM`, `aquarius_amm`, …). The source count is derived from that +//! list rather than passed in, so the contract can never record a price that +//! claims more sources than it can name. + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, BytesN, Env, + Symbol, Vec, +}; + +/// SEP-40 §Interface: a Stellar asset is identified by its Stellar Asset +/// Contract address (issuer-qualified by construction); anything else — and +/// native XLM, which has no asset contract — is identified by a symbol the +/// oracle defines. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Asset { + Stellar(Address), + Other(Symbol), +} + +/// SEP-40 §Interface: "Price data for an asset at a specific timestamp". +/// Fields are `pub` (the spec's snippet omits that, and private fields cannot +/// be read by a consumer contract). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceData { + /// Scaled by `decimals()`: real price = `price / 10^decimals`. + pub price: i128, + /// Unix seconds, trimmed to `resolution()`. + pub timestamp: u64, +} + +/// What Lens actually pushed, and what it was made of. Consumer contracts do +/// not need this to read a price (SEP-40 is enough for that), but they need it +/// to answer "how fresh is this, and which venues produced it" — the same two +/// guarantees the HTTP API makes about the aggregate it serves. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Report { + pub price: i128, + /// SEP-40 feed timestamp (trimmed to `resolution()`). + pub timestamp: u64, + /// Ledger close time of the on-chain write — when the feed value became + /// available, as opposed to when the market tick it describes occurred. + pub reported_at: u64, + /// Ledger the write landed in. + pub reported_ledger: u32, + /// Number of venues behind the aggregate; always `venues.len()`. + pub source_count: u32, + /// Which venues those were. + pub venues: Vec, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// `initialize` has already run. Config is immutable on purpose: SEP-40 + /// says decimals/resolution "should never change" once deployed. + AlreadyInitialized = 1, + NonPositivePrice = 2, + /// A feed timestamp that is not a whole number of resolutions would break + /// the trimming rule every consumer relies on. + TimestampNotAligned = 3, + /// Reports must move forward; an out-of-order write cannot rewrite history. + NotNewer = 4, + /// The publisher's clock is ahead of the ledger, or the write is replayed + /// from far in the past. Either way the value is not publishable. + TimestampInFuture = 5, + /// A price with no venue behind it is worse than no price. + NoVenues = 6, + /// Bounds the per-asset storage footprint. + TooManyVenues = 7, + /// `decimals` or `resolution` was zero; both are load-bearing for every + /// price and timestamp in the feed, so they are refused rather than stored. + InvalidConfig = 8, +} + +#[contracttype] +enum DataKey { + Admin, + Publisher, + Base, + Decimals, + Resolution, + /// Every asset that has ever been reported, in first-seen order, so + /// `assets()` can enumerate the feed. + Assets, + /// Per-asset history, newest first. + History(Asset), + /// Per-asset last known price (SEP-40 `lastprice`). + Last(Asset), + /// Per-asset last report with its provenance. + LastReport(Asset), +} + +/// Price records kept per asset. `prices()` reads from this, and it is trimmed +/// on every write so the feed cannot grow without bound. +const MAX_HISTORY: u32 = 64; +/// Venue names accepted in one report. Well above the number of venues Lens +/// aggregates today, and low enough to keep a report's storage small. +const MAX_VENUES: u32 = 16; +/// Persistent-entry TTL policy: extend when an entry is within `THRESHOLD` +/// ledgers of expiry, by `BUMP` ledgers (roughly 4 days at ~5 s per ledger). +const TTL_THRESHOLD: u32 = 5_000; +const TTL_BUMP: u32 = 40_000; + +#[contract] +pub struct Sep40OracleContract; + +#[contractimpl] +impl Sep40OracleContract { + // ── Admin ──────────────────────────────────────────────────────────────── + + /// One-shot configuration, called by the deployer. `base` is the asset every + /// feed is denominated in (SEP-40 § Specification: "all price feeds … in + /// the base asset"), `decimals` the scale of `PriceData.price`, and + /// `resolution` the timeframe feeds are trimmed to. + /// + /// Never callable twice: SEP-40 requires these values to be stable once + /// deployed, because consumers cannot be expected to react to a change. + pub fn initialize( + env: Env, + admin: Address, + base: Asset, + decimals: u32, + resolution: u32, + ) -> Result<(), Error> { + admin.require_auth(); + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + if decimals == 0 || resolution == 0 { + // A zero resolution would make every timestamp check divide by zero + // and a zero decimal count would silently round every price to an + // integer, so both are refused rather than stored. + return Err(Error::InvalidConfig); + } + + let storage = env.storage().instance(); + storage.set(&DataKey::Admin, &admin); + storage.set(&DataKey::Base, &base); + storage.set(&DataKey::Decimals, &decimals); + storage.set(&DataKey::Resolution, &resolution); + storage.set(&DataKey::Assets, &Vec::::new(&env)); + storage.extend_ttl(TTL_THRESHOLD, TTL_BUMP); + Ok(()) + } + + /// Point the feed at a hot publisher key. The publisher signs `report`; + /// the admin key stays cold and only ever signs `initialize`, + /// `set_publisher` and `upgrade`. Unset, `report` falls back to the admin. + pub fn set_publisher(env: Env, publisher: Address) { + Self::require_admin(&env); + env.storage().instance().set(&DataKey::Publisher, &publisher); + } + + /// Swap the implementation without changing the contract address, which + /// SEP-40 requires ("it must be upgradeable without changing the contract + /// address. Smart contract consumers should only be expected to change + /// their price feed aggregator address if they would like to change oracle + /// providers"). + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + Self::require_admin(&env); + env.deployer().update_current_contract_wasm(new_wasm_hash); + } + + /// Publish one aggregate value for one asset. This is the provider half — + /// out of SEP-40's scope, so its shape is Lens's own. + /// + /// `timestamp` must already be trimmed to `resolution()` (Lens does that + /// when it builds the report; the contract re-checks it so a consumer can + /// take the trimming as a guarantee rather than a convention). + pub fn report( + env: Env, + asset: Asset, + price: i128, + timestamp: u64, + venues: Vec, + ) -> Result<(), Error> { + Self::require_publisher(&env); + + if price <= 0 { + return Err(Error::NonPositivePrice); + } + if venues.is_empty() { + return Err(Error::NoVenues); + } + if venues.len() > MAX_VENUES { + return Err(Error::TooManyVenues); + } + + let resolution: u32 = env + .storage() + .instance() + .get(&DataKey::Resolution) + .ok_or(Error::TimestampNotAligned)?; + if timestamp % resolution as u64 != 0 { + return Err(Error::TimestampNotAligned); + } + if timestamp > env.ledger().timestamp() { + return Err(Error::TimestampInFuture); + } + + let data = PriceData { price, timestamp }; + if let Some(last) = Self::read_last(&env, &asset) { + if timestamp <= last.timestamp { + return Err(Error::NotNewer); + } + } + + // History is newest-first so `prices(records)` and `price(timestamp)` + // read from the front, and the trim happens at the back. + let mut history = Self::read_history(&env, &asset); + history.push_front(data.clone()); + while history.len() > MAX_HISTORY { + history.pop_back(); + } + + let report = Report { + price, + timestamp, + reported_at: env.ledger().timestamp(), + reported_ledger: env.ledger().sequence(), + source_count: venues.len(), + venues, + }; + + let history_key = DataKey::History(asset.clone()); + let last_key = DataKey::Last(asset.clone()); + let report_key = DataKey::LastReport(asset.clone()); + let storage = env.storage().persistent(); + storage.set(&history_key, &history); + storage.set(&last_key, &data); + storage.set(&report_key, &report); + storage.extend_ttl(&history_key, TTL_THRESHOLD, TTL_BUMP); + storage.extend_ttl(&last_key, TTL_THRESHOLD, TTL_BUMP); + storage.extend_ttl(&report_key, TTL_THRESHOLD, TTL_BUMP); + + // Enumerate this asset from `assets()` from now on. + let mut assets: Vec = env + .storage() + .instance() + .get(&DataKey::Assets) + .unwrap_or(Vec::new(&env)); + if !Self::contains_asset(&assets, &asset) { + assets.push_back(asset.clone()); + env.storage().instance().set(&DataKey::Assets, &assets); + } + + env.events().publish( + ("lens_oracle", symbol_short!("report")), + (asset.clone(), price, timestamp), + ); + Ok(()) + } + + // ── SEP-40 PriceFeedTrait ──────────────────────────────────────────────── + + /// The asset every price in this feed is denominated in. + pub fn base(env: Env) -> Asset { + env.storage() + .instance() + .get(&DataKey::Base) + .expect("oracle is not initialized") + } + + /// All assets this feed quotes. + pub fn assets(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::Assets) + .unwrap_or(Vec::new(&env)) + } + + /// Scale of `PriceData.price` for every asset in this feed. + pub fn decimals(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Decimals) + .expect("oracle is not initialized") + } + + /// Default tick period, in seconds, that feed timestamps are trimmed to. + pub fn resolution(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Resolution) + .expect("oracle is not initialized") + } + + /// The feed value in effect at `timestamp` — the newest record at or before + /// it, or `None`. + /// + /// SEP-40 phrases this as "price information for a given point in time, + /// under condition that the historical data for a requested timestamp is + /// available". A deviation-triggered feed is deliberately non-uniform, so + /// an exact timestamp is usually absent; nearest-at-or-before is the + /// extrapolation the spec asks an oracle to present instead. Unknown assets + /// and timestamps older than the retained history return `None` rather than + /// erroring, per the spec's instruction to delegate error handling to the + /// consumer. + pub fn price(env: Env, asset: Asset, timestamp: u64) -> Option { + let history = Self::read_history(&env, &asset); + let mut best: Option = None; + for record in history.iter() { + if record.timestamp <= timestamp { + best = Some(record); + break; + } + } + best + } + + /// The last `records` prices for an asset, newest first. `None` when the + /// asset has no history, and `None` for `records == 0`, which is not a + /// question the feed can answer. Fewer than `records` may be returned when + /// the feed has not published that many values yet, or when `records` + /// exceeds the retained history (`MAX_HISTORY`). + pub fn prices(env: Env, asset: Asset, records: u32) -> Option> { + // A request for zero records is invalid input, and SEP-40 asks the + // oracle to hand invalid input back as `None` rather than as a value + // that looks like an answer. + if records == 0 { + return None; + } + let history = Self::read_history(&env, &asset); + if history.is_empty() { + return None; + } + // Asking for more than the feed has kept is not an error: the feed + // answers with everything it has, newest first. + let take = if records > history.len() { history.len() } else { records }; + let mut out = Vec::::new(&env); + for i in 0..take { + out.push_back(history.get(i)?); + } + Some(out) + } + + /// The most recent price for an asset. + pub fn lastprice(env: Env, asset: Asset) -> Option { + Self::read_last(&env, &asset) + } + + // ── Lens-specific reads (beyond SEP-40) ────────────────────────────────── + + /// Full provenance of the last write: when it landed on chain, in which + /// ledger, and which venues produced the value. A consumer that wants to + /// hold the same bar as the HTTP API's `stale` / source fields reads this. + pub fn last_report(env: Env, asset: Asset) -> Option { + env.storage().persistent().get(&DataKey::LastReport(asset)) + } + + // ── Internals ──────────────────────────────────────────────────────────── + + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("oracle is not initialized"); + admin.require_auth(); + } + + /// `report` is authorized by the publisher when one has been set, and by + /// the admin otherwise. Exactly one address authorizes a write, so there is + /// no ambiguity about which key the operator has to hold. + fn require_publisher(env: &Env) { + let storage = env.storage().instance(); + let publisher: Option
= storage.get(&DataKey::Publisher); + match publisher { + Some(publisher) => publisher.require_auth(), + None => Self::require_admin(env), + } + } + + fn read_last(env: &Env, asset: &Asset) -> Option { + let key = DataKey::Last(asset.clone()); + let storage = env.storage().persistent(); + let data: Option = storage.get(&key); + if data.is_some() { + storage.extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP); + } + data + } + + fn read_history(env: &Env, asset: &Asset) -> Vec { + env.storage() + .persistent() + .get(&DataKey::History(asset.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + fn contains_asset(assets: &Vec, asset: &Asset) -> bool { + for existing in assets.iter() { + if equal_assets(&existing, asset) { + return true; + } + } + false + } +} + +/// Two `Asset`s are the same asset when they are the same variant holding the +/// same value. Written as a match rather than derived: `Asset` is the type the +/// spec pins, and comparing the two payload kinds directly is all the identity +/// check the registry needs. +fn equal_assets(a: &Asset, b: &Asset) -> bool { + match (a, b) { + (Asset::Stellar(x), Asset::Stellar(y)) => x == y, + (Asset::Other(x), Asset::Other(y)) => x == y, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::InvokeError; + use soroban_sdk::testutils::{Address as _, AuthorizedFunction, Ledger as _}; + + const DECIMALS: u32 = 14; + const RESOLUTION: u32 = 60; + /// Ledger time the test env starts at, so a report can be "now". + const NOW: u64 = 1_760_000_040; + + fn sym(env: &Env, name: &str) -> Symbol { + Symbol::new(env, name) + } + + fn venues(env: &Env, names: &[&str]) -> Vec { + let mut out = Vec::new(env); + for name in names { + out.push_back(sym(env, name)); + } + out + } + + /// XLM quoted in USDC, as the SEP-40 `Other(Symbol)` form. + fn xlm(env: &Env) -> Asset { + Asset::Other(sym(env, "XLM")) + } + + struct Feed { + env: Env, + client: Sep40OracleContractClient<'static>, + admin: Address, + publisher: Address, + } + + fn feed(timestamp: u64) -> Feed { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(timestamp); + let contract_id = env.register(Sep40OracleContract, ()); + let client = Sep40OracleContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let publisher = Address::generate(&env); + client.initialize(&admin, &Asset::Other(sym(&env, "USDC")), &DECIMALS, &RESOLUTION); + client.set_publisher(&publisher); + Feed { env, client, admin, publisher } + } + + /// Publish at `timestamp`, taking the ledger clock with it. A real publisher + /// can only report a tick the ledger has already reached (`report` refuses a + /// timestamp ahead of ledger time), so a test that walks forward through the + /// day has to walk the clock forward too. + fn report_at(f: &Feed, asset: &Asset, price: i128, timestamp: u64, names: &[&str]) { + f.env.ledger().set_timestamp(timestamp); + f.client + .report(asset, &price, ×tamp, &venues(&f.env, names)); + } + + #[test] + fn initialize_publishes_the_feed_parameters() { + let f = feed(NOW); + assert_eq!(f.client.decimals(), DECIMALS); + assert_eq!(f.client.resolution(), RESOLUTION); + assert_eq!(f.client.base(), Asset::Other(sym(&f.env, "USDC"))); + // Nothing reported yet, so nothing is quoted. + assert_eq!(f.client.assets().len(), 0); + } + + #[test] + fn initialize_cannot_run_twice() { + let f = feed(NOW); + let result = f.client.try_initialize( + &f.admin, + &Asset::Other(sym(&f.env, "EUR")), + &DECIMALS, + &RESOLUTION, + ); + assert_eq!(result.err(), Some(Ok(Error::AlreadyInitialized))); + } + + #[test] + fn zero_resolution_or_decimals_is_refused() { + // A zero resolution makes every timestamp check divide by zero; a zero + // decimal count silently rounds every price to an integer. + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(Sep40OracleContract, ()); + let client = Sep40OracleContractClient::new(&env, &id); + let admin = Address::generate(&env); + let zero_resolution = + client.try_initialize(&admin, &Asset::Other(sym(&env, "USDC")), &DECIMALS, &0); + assert_eq!(zero_resolution.err(), Some(Ok(Error::InvalidConfig))); + let zero_decimals = + client.try_initialize(&admin, &Asset::Other(sym(&env, "USDC")), &0, &RESOLUTION); + assert_eq!(zero_decimals.err(), Some(Ok(Error::InvalidConfig))); + } + + #[test] + fn a_reported_price_is_readable_through_sep_40() { + let f = feed(NOW); + // 0.025 USDC per XLM at 14 decimals. + let price: i128 = 250_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX", "AMM"])); + + let last = f.client.lastprice(&xlm(&f.env)).expect("price"); + assert_eq!(last.price, price); + assert_eq!(last.timestamp, NOW); + + assert_eq!(f.client.assets().len(), 1); + let historical = f.client.price(&xlm(&f.env), &NOW).expect("price at now"); + assert_eq!(historical.price, price); + let history = f.client.prices(&xlm(&f.env), &10).expect("history"); + assert_eq!(history.len(), 1); + } + + #[test] + fn last_report_names_the_venues_behind_the_number() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client.report( + &xlm(&f.env), + &price, + &NOW, + &venues(&f.env, &["SDEX", "AMM", "aquarius_amm"]), + ); + + let report = f.client.last_report(&xlm(&f.env)).expect("report"); + assert_eq!(report.source_count, 3); + assert_eq!(report.venues.len(), 3); + assert_eq!(report.venues.get(2).unwrap(), sym(&f.env, "aquarius_amm")); + // The write landed in this ledger, at this close time. + assert_eq!(report.reported_at, NOW); + assert_eq!(report.reported_ledger, f.env.ledger().sequence()); + } + + #[test] + fn history_is_newest_first() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + for offset in [0_u64, 60, 120] { + report_at(&f, &xlm(&f.env), price, NOW + offset, &["SDEX"]); + } + let history = f.client.prices(&xlm(&f.env), &10).expect("history"); + assert_eq!(history.len(), 3); + assert_eq!(history.get(0).unwrap().timestamp, NOW + 120); + assert_eq!(history.get(2).unwrap().timestamp, NOW); + } + + #[test] + fn price_at_returns_the_nearest_value_at_or_before() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); + // A deviation-triggered feed has no record at every resolution tick; + // asking for a later one still yields the value in effect. + assert_eq!(f.client.price(&xlm(&f.env), &(NOW + 300)).unwrap().timestamp, NOW); + // …and one from before the first report has nothing in effect yet. + assert_eq!(f.client.price(&xlm(&f.env), &(NOW - 60)), None); + } + + #[test] + fn an_unquoted_asset_reads_as_none_rather_than_an_error() { + // SEP-40: "a contract should return None on such function calls in + // order to delegate error handling logic directly to consumer + // contracts". + let f = feed(NOW); + let eur = Asset::Other(sym(&f.env, "EUR")); + assert_eq!(f.client.lastprice(&eur), None); + assert_eq!(f.client.prices(&eur, &5), None); + assert_eq!(f.client.price(&eur, &NOW), None); + assert_eq!(f.client.last_report(&eur), None); + } + + #[test] + fn a_report_that_does_not_move_the_feed_forward_is_rejected() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); + + let older = f.client.try_report( + &xlm(&f.env), + &(price + 1), + &(NOW - 60), + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(older.err(), Some(Ok(Error::NotNewer))); + + // A replay of the same tick is refused for the same reason, so a + // retried publish cannot rewrite the value consumers already read. + let same = f.client.try_report( + &xlm(&f.env), + &price, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(same.err(), Some(Ok(Error::NotNewer))); + } + + #[test] + fn a_feed_timestamp_must_be_trimmed_to_the_resolution() { + // SEP-40 defines the timestamp as floor(unix_now() / resolution) * + // resolution. Enforcing it here means a consumer can treat trimming as + // a guarantee instead of a convention. + let f = feed(NOW); + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &(NOW + 1), + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(result.err(), Some(Ok(Error::TimestampNotAligned))); + } + + #[test] + fn a_timestamp_from_the_future_is_rejected() { + // A publisher with a clock ahead of the ledger would otherwise hand + // consumers a price that never looks stale. + let f = feed(NOW); + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &(NOW + 600), + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(result.err(), Some(Ok(Error::TimestampInFuture))); + } + + #[test] + fn a_price_with_no_venue_or_no_magnitude_is_rejected() { + let f = feed(NOW); + let none = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &Vec::::new(&f.env), + ); + assert_eq!(none.err(), Some(Ok(Error::NoVenues))); + + let zero = f.client.try_report( + &xlm(&f.env), + &0, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(zero.err(), Some(Ok(Error::NonPositivePrice))); + + let negative = f.client.try_report( + &xlm(&f.env), + &-1, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(negative.err(), Some(Ok(Error::NonPositivePrice))); + } + + #[test] + fn more_venues_than_the_feed_will_store_is_rejected() { + let f = feed(NOW); + let names: [&str; 17] = [ + "SDEX", "AMM", "a_1", "a_2", "a_3", "a_4", "a_5", "a_6", "a_7", "a_8", "a_9", + "b_1", "b_2", "b_3", "b_4", "b_5", "b_6", + ]; + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &names), + ); + assert_eq!(result.err(), Some(Ok(Error::TooManyVenues))); + } + + #[test] + fn history_stays_bounded() { + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + let writes = MAX_HISTORY + 25; + for i in 0..writes { + let timestamp = NOW + 60 * (i as u64) + 60; + report_at(&f, &xlm(&f.env), price, timestamp, &["SDEX"]); + } + assert_eq!(f.client.prices(&xlm(&f.env), &1_000).unwrap().len(), MAX_HISTORY); + // The oldest retained record is the first one still inside the window: + // everything older than the newest `MAX_HISTORY` writes is gone. + assert_eq!( + f.client.prices(&xlm(&f.env), &1_000).unwrap().get(MAX_HISTORY - 1).unwrap().timestamp, + NOW + 60_u64 * (writes - MAX_HISTORY) as u64 + 60 + ); + } + + #[test] + fn asking_for_more_records_than_exist_returns_what_there_is() { + let f = feed(NOW); + f.client.report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(f.client.prices(&xlm(&f.env), &100).unwrap().len(), 1); + // Zero records is invalid input, so it reads as "nothing", not an error. + assert_eq!(f.client.prices(&xlm(&f.env), &0), None); + } + + #[test] + fn each_quoted_asset_keeps_its_own_history() { + let f = feed(NOW); + f.client.report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + let btc = Asset::Other(sym(&f.env, "BTC")); + f.client.report( + &btc, + &60_000_000_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + + assert_eq!(f.client.assets().len(), 2); + assert!(f.client.lastprice(&btc).is_some()); + // A write to one asset cannot disturb another. + assert_eq!(f.client.prices(&btc, &10).unwrap().len(), 1); + assert_eq!(f.client.prices(&xlm(&f.env), &10).unwrap().len(), 1); + } + + #[test] + fn a_write_without_the_publishers_authorization_does_not_land() { + // `report` accepts exactly one signer: the publisher address stored in + // the feed. Turning the env's auth mocking off makes the call carry + // nobody's authorization, so the write is refused before any of the + // price rules are reached — and the read surface is untouched. + let f = feed(NOW); + f.env.set_auths(&[]); + + let result = f.client.try_report( + &xlm(&f.env), + &1_000_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + // The refusal comes from the auth layer, not from one of the feed's own + // rules: a contract error would surface as `Err(Ok(Error::…))`. + assert_eq!( + result.err(), + Some(Err(InvokeError::Abort)), + "a write without the publisher's authorization must not land" + ); + assert_eq!(f.client.lastprice(&xlm(&f.env)), None); + assert_eq!(f.client.assets().len(), 0); + } + + #[test] + fn the_publisher_is_the_address_that_signs_a_write() { + // With auth recording enabled the call still succeeds, but the feed + // reports *whose* authorization it demanded: the publisher, never the + // admin and never the signer of the transaction. + let f = feed(NOW); + let price: i128 = 1_000_000_000_000_000; + f.client + .report(&xlm(&f.env), &price, &NOW, &venues(&f.env, &["SDEX"])); + + let mut auths = f.env.auths(); + assert_eq!(auths.len(), 1, "a report demands exactly one signature"); + let (signer, invocation) = auths.pop().expect("one authorization"); + assert_eq!(signer, f.publisher); + match invocation.function { + AuthorizedFunction::Contract((contract, fn_name, _)) => { + assert_eq!(contract, f.client.address); + assert_eq!(fn_name, sym(&f.env, "report")); + } + _ => panic!("a report is authorized by a contract invocation"), + } + } + + #[test] + fn a_stellar_asset_is_quoted_by_its_asset_contract_address() { + // SEP-40 represents a Stellar asset by the address of its Stellar Asset + // Contract, which is what makes the identifier issuer-qualified: two + // tokens both called USDC are different assets here. + let f = feed(NOW); + let usdc = Asset::Stellar(Address::generate(&f.env)); + f.client.report( + &usdc, + &40_000_000_000_000, + &NOW, + &venues(&f.env, &["SDEX"]), + ); + assert_eq!(f.client.lastprice(&usdc).unwrap().price, 40_000_000_000_000); + assert_eq!(f.client.assets().get(0).unwrap(), usdc); + } + + #[test] + fn a_second_report_of_the_same_asset_does_not_duplicate_the_registry() { + let f = feed(NOW); + for offset in [60_u64, 120] { + report_at(&f, &xlm(&f.env), 1_000_000_000_000_000, NOW + offset, &["SDEX"]); + } + assert_eq!(f.client.assets().len(), 1); + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f8838c4..533d663f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -271,3 +271,71 @@ model ApiKey { @@map("api_keys") } + +/// One value Lens put on chain through its SEP-40 feed (#222). +/// +/// Rows exist for WRITES, not for decisions: a blocked tick is a log line, and +/// a table that recorded every "below_deviation" would hold ~2,880 rows per +/// pair per day and say nothing an operator could not read from the trigger +/// code in the logs. What this table proves is the opposite direction — every +/// number a contract was told, with the venues that produced it, which is the +/// evidence that "a price you cannot source is worse than no price" was +/// actually enforced. +model OraclePublication { + id String @id @default(uuid()) + network String @default("testnet") + + /// The Lens pair this came from. Kept alongside the feed asset because one + /// pair can only ever feed one quoted asset, but the reverse is not true: + /// without pairKey you cannot trace a published number back to a trade. + pairKey String @map("pair_key") + + /// SEP-40 identifier of the asset whose price in `base` this is: a C... + /// Stellar Asset Contract id, or the code for native XLM. Never a bare code + /// for an anchored asset — a code is not an issuer, and two tokens share one + /// code in the wild. + quotedAsset String @map("quoted_asset") + baseAsset String @map("base_asset") + + /// "published" | "failed". "published" is written only after the transaction + /// finalized, so a row here is a claim about the ledger, not about intent. + state String + + /// Why the write happened: "first_report" | "deviation" | "max_interval". + trigger String? + + /// Human-readable price, the same number /api/price would return for the + /// pair at that moment, oriented against the feed's base. + price Decimal? @db.Decimal(36, 18) + + /// The exact i128 written on chain, as text. Prisma's Decimal cannot hold an + /// i128 range, and re-deriving it from `price` would defeat the point of + /// keeping it. + scaledPrice String? @map("scaled_price") + decimals Int? + /// Feed tick the value was written at (`floor(unix_now()/resolution)*resolution`). + feedTimestamp BigInt? @map("feed_timestamp") + resolution Int? + + /// Every venue behind the number, joined with ','. This is the provenance + /// guarantee: a row without venues should not exist, and `sourceCount` is + /// `venues.length` because the contract derives it from the same list. + venues String? + sourceCount Int? @map("source_count") + + /// Percent moved against the value that was on chain, when there was one. + deviationPct Float? @map("deviation_pct") + + /// The publisher's own sentence for the decision — the same string that went + /// to the log, so a log grep and a table query agree. + reason String? + + txHash String? @map("tx_hash") + errorMessage String? @map("error_message") + + createdAt DateTime @default(now()) @map("created_at") + + @@index([network, pairKey, createdAt(sort: Desc)]) + @@index([network, quotedAsset, feedTimestamp(sort: Desc)]) + @@map("oracle_publications") +} diff --git a/src/__tests__/sep40Encoding.test.ts b/src/__tests__/sep40Encoding.test.ts new file mode 100644 index 00000000..8fcbde13 --- /dev/null +++ b/src/__tests__/sep40Encoding.test.ts @@ -0,0 +1,222 @@ +/** + * SEP-40 wire format (#222). + * + * These are the tests that matter most for an oracle, because the failure mode + * here is not an exception — it is a number that looks right and is wrong by + * four orders of magnitude, read by a contract that trusts it. So the scale, + * the trimming and the asset identity each get pinned against an independent + * derivation rather than against the function under test. + */ +import { describe, it, expect, vi } from 'vitest' +import { Asset, Networks, StrKey } from '@stellar/stellar-sdk' + +vi.mock('../config', () => ({ + activeNetwork: 'testnet', + getNetworkConfig: (network: string) => ({ + network: { + passphrase: + network === 'mainnet' + ? Networks.PUBLIC + : 'Test SDF Network ; September 2015', + }, + }), +})) + +import { + Sep40EncodingError, + assetFromScVal, + assetFromSpecArg, + assetKey, + assetToScVal, + assetToSpecArg, + scalePrice, + toSep40Asset, + trimTimestamp, + unscalePrice, + venuesToSpecArg, +} from '../publish/sep40' + +// The testnet USDC issuer from .env.example's WATCHED_PAIRS entry, verbatim. +const TESTNET_USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +// The mainnet USDC issuer Lens watches, and the Stellar Asset Contract id that +// issuer is known by on mainnet. Both halves are written out here on purpose: +// the assertion is that the derivation in sep40.ts reproduces the published +// address, which it can only do if code AND issuer are both carried through. +const MAINNET_USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const MAINNET_USDC_SAC = 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' + +describe('scalePrice / unscalePrice', () => { + it('scales through fixed-point, not a float multiply', () => { + // 0.29 * 1e14 is 28999999999999.996 — not an integer, so BigInt() of the + // product throws and Math.round() of it would publish 28999999999999. The + // string path is exact, and this pins which one ran. + expect(Number.isInteger(0.29 * 1e14)).toBe(false) + expect(scalePrice(0.29, 14)).toBe(29_000_000_000_000n) + expect(scalePrice(0.1, 14)).toBe(10_000_000_000_000n) + expect(scalePrice(1.5, 7)).toBe(15_000_000n) + expect(scalePrice(123.456, 0)).toBe(123n) + }) + + it('round-trips a price back to the value a consumer would read', () => { + for (const price of [0.0000001, 1, 2.71828, 19.95, 4321.5]) { + expect(unscalePrice(scalePrice(price, 14), 14)).toBeCloseTo(price, 10) + } + }) + + it('refuses a price that is not a positive finite number', () => { + for (const bad of [0, -1, NaN, Infinity]) { + expect(() => scalePrice(bad, 14)).toThrow(Sep40EncodingError) + } + }) + + it('refuses decimals the feed could not have been configured with', () => { + expect(() => scalePrice(1, -1)).toThrow(/between 0 and 36/) + expect(() => scalePrice(1, 37)).toThrow(/between 0 and 36/) + expect(() => scalePrice(1, 1.5)).toThrow(/between 0 and 36/) + }) + + it('refuses a scaled value that overflows i128', () => { + // 1e20 * 1e36 is 1e56, well over the 1.7e38 an i128 holds, while the same + // price at 14 decimals (1e34) fits comfortably. + expect(scalePrice(1e20, 14)).toBe(10n ** 34n) + expect(() => scalePrice(1e20, 36)).toThrow(/overflows i128/) + }) + + it('refuses a price it cannot even write in fixed-point', () => { + // Number#toFixed switches to exponential notation at 1e21, where the digit + // string is no longer a number. Better a named error than a rejected tx. + expect(() => scalePrice(1e24, 14)).toThrow(/cannot be written in fixed-point/) + }) +}) + +describe('trimTimestamp', () => { + it('floors to the feed tick, which is what SEP-40 defines', () => { + // 1_760_000_049 is 9 seconds into a 60-second tick. + expect(trimTimestamp(1_760_000_049_000, 60)).toBe(1_760_000_040) + expect(trimTimestamp(1_760_000_040_000, 60)).toBe(1_760_000_040) + // One millisecond before the tick belongs to the tick before it — that is + // what "floor" means, and it is why a report never claims a future tick. + expect(trimTimestamp(1_760_000_039_999, 60)).toBe(1_759_999_980) + expect(trimTimestamp(1_760_000_049_000, 1)).toBe(1_760_000_049) + }) + + it('refuses a resolution that cannot divide a timestamp', () => { + expect(() => trimTimestamp(Date.now(), 0)).toThrow(/positive whole number/) + expect(() => trimTimestamp(Date.now(), 2.5)).toThrow(/positive whole number/) + }) +}) + +describe('toSep40Asset', () => { + it('names native XLM by symbol, because it has no asset contract', () => { + expect(toSep40Asset({ code: 'XLM', issuer: null }, 'testnet')).toEqual({ + kind: 'other', + symbol: 'XLM', + }) + }) + + it('derives the anchored asset id from code and issuer rather than a list', () => { + const asset = toSep40Asset({ code: 'USDC', issuer: TESTNET_USDC_ISSUER }, 'testnet') + expect(asset.kind).toBe('stellar') + if (asset.kind === 'stellar') { + expect(StrKey.isValidContract(asset.contractId)).toBe(true) + // Independent derivation: the SDK hashes the asset's own LedgerKey. + expect(asset.contractId).toBe( + new Asset('USDC', TESTNET_USDC_ISSUER).contractId('Test SDF Network ; September 2015') + ) + } + }) + + it('reproduces the published mainnet USDC contract address', () => { + const asset = toSep40Asset({ code: 'USDC', issuer: MAINNET_USDC_ISSUER }, 'mainnet') + expect(asset).toEqual({ kind: 'stellar', contractId: MAINNET_USDC_SAC }) + // and the same derivation the assertion is checked against + expect(new Asset('USDC', MAINNET_USDC_ISSUER).contractId(Networks.PUBLIC)).toBe(MAINNET_USDC_SAC) + }) + + it('keeps two tokens that share a code apart', () => { + const real = toSep40Asset({ code: 'USDC', issuer: TESTNET_USDC_ISSUER }, 'testnet') + // A second issuer that is structurally valid, built from raw bytes rather + // than typed by hand: the point of the assertion is that two *resolvable* + // issuers give two different assets, which a mangled address cannot show. + const otherIssuer = StrKey.encodeEd25519PublicKey(Uint8Array.from({ length: 32 }, (_, i) => i + 1)) + const imposter = toSep40Asset({ code: 'USDC', issuer: otherIssuer }, 'testnet') + expect(assetKey(real)).not.toBe(assetKey(imposter)) + // A near-miss address is a refusal, not a second asset. + expect(() => + toSep40Asset( + { code: 'USDC', issuer: 'GBBE47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + 'testnet' + ) + ).toThrow(/not a valid G/) + }) + + it('refuses an anchored asset whose issuer is not an account id', () => { + expect(() => toSep40Asset({ code: 'USDC', issuer: 'not-an-account' }, 'testnet')).toThrow( + /not a valid G\.\.\. account id/ + ) + }) +}) + +describe('Asset encoding on the wire', () => { + it('round-trips both variants through the spec union', () => { + const stellar: Parameters[0] = { + kind: 'stellar', + contractId: MAINNET_USDC_SAC, + } + expect(assetToSpecArg(stellar)).toEqual({ tag: 'Stellar', values: [MAINNET_USDC_SAC] }) + expect(assetFromSpecArg(assetToSpecArg(stellar))).toEqual(stellar) + + const other = { kind: 'other', symbol: 'XLM' } as const + expect(assetToSpecArg(other)).toEqual({ tag: 'Other', values: ['XLM'] }) + expect(assetFromSpecArg(assetToSpecArg(other))).toEqual(other) + }) + + it('encodes a contracttype enum variant as Vec[Symbol(variant), value]', () => { + const stellar = { kind: 'stellar', contractId: MAINNET_USDC_SAC } as const + const vec = assetToScVal(stellar).vec() + expect(vec).toHaveLength(2) + expect(vec![0].sym().toString()).toBe('Stellar') + expect(assetFromScVal(assetToScVal(stellar))).toEqual(stellar) + expect(assetFromScVal(assetToScVal({ kind: 'other', symbol: 'XLM' }))).toEqual({ + kind: 'other', + symbol: 'XLM', + }) + // The Stellar branch carries an ADDRESS, not a string: a consumer decoding + // the vec gets back the same contract id it asked about. + const other = { kind: 'other', symbol: 'XLM' } as const + const otherVec = assetToScVal(other).vec() + expect(otherVec![0].sym().toString()).toBe('Other') + expect(otherVec![1].sym().toString()).toBe('XLM') + }) + + it('rejects a value that is not an Asset union', () => { + expect(() => assetFromSpecArg({ tag: 'Stellar', values: ['GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'] })).toThrow( + /not a C\.\.\. contract id/ + ) + expect(() => assetFromSpecArg('XLM')).toThrow(/expected a SEP-40 Asset/) + expect(() => assetFromSpecArg({ tag: 'Weird', values: ['x'] })).toThrow(/unknown SEP-40 Asset variant/) + }) +}) + +describe('venuesToSpecArg', () => { + it('refuses to name zero venues behind a number', () => { + // This is the instruction the whole feature hangs on, so the message is + // asserted too: a silent skip is how an unsourced price gets through. + expect(() => venuesToSpecArg([])).toThrow(/a price you cannot source is worse than no price/) + }) + + it('caps the list at what the contract stores', () => { + const many = Array.from({ length: 17 }, (_, i) => `venue_${i}`) + expect(() => venuesToSpecArg(many)).toThrow(/exceed the feed's limit of 16/) + }) + + it('refuses a venue name the ledger cannot hold', () => { + expect(() => venuesToSpecArg(['a_venue_name_that_is_definitely_longer_than_thirty_two_bytes'])).toThrow( + /Soroban symbol limit/ + ) + }) + + it('dedupes and sorts so the same sources encode identically', () => { + expect(venuesToSpecArg(['soroswap_amm', 'SDEX', 'SDEX'])).toEqual(['SDEX', 'soroswap_amm']) + }) +}) diff --git a/src/__tests__/sep40Publisher.test.ts b/src/__tests__/sep40Publisher.test.ts new file mode 100644 index 00000000..524133fb --- /dev/null +++ b/src/__tests__/sep40Publisher.test.ts @@ -0,0 +1,303 @@ +/** + * The publisher cycle (#222), against a fake feed. + * + * What is asserted here is the behaviour an operator can only check after the + * fact on chain — so it is checked before the fact, with a feed that records + * every call. The most important assertion in this file is a `not.toHaveProperty` + * / `toHaveBeenCalledTimes(0)` pair: a blocked tick must not touch the chain at + * all, because "we read it, decided it was stale, and wrote it anyway" and "we + * never asked" look identical from the API. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Asset } from '@stellar/stellar-sdk' + +const { configMock } = vi.hoisted(() => { + const pair = { + assetA: { code: 'XLM', issuer: null as string | null }, + assetB: { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + pairKey: 'XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + } + return { + configMock: { + activeNetwork: 'testnet' as const, + pair, + config: { + publisher: { + deviationPct: 0.5, + maxIntervalMs: 900_000, + minWriteIntervalMs: 60_000, + maxAgeSeconds: 120, + pollIntervalMs: 30_000, + }, + }, + getNetworkConfig: (network: string) => ({ + network: { + passphrase: + network === 'mainnet' + ? 'Public Global Stellar Network ; September 2015' + : 'Test SDF Network ; September 2015', + }, + publisher: { + enabled: true, + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCD3', + secretKey: 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCD3', + pairs: [pair], + }, + pairs: [pair], + }), + }, + } +}) + +vi.mock('../config', () => configMock) +vi.mock('../db', () => ({ + pgPool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, + prisma: { oraclePublication: { create: vi.fn().mockResolvedValue({}) } }, + upsertPricePoints: vi.fn(), +})) +vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) +vi.mock('../ingest/venues/runner', () => ({ sleep: async () => {} })) + +import { runPublicationCycle, type PublicationDeps, type AggregateSource } from '../publish/publisher' +import { FeedUnavailableError, FeedWriteError, type Sep40Feed } from '../publish/onchain' +import type { Sep40Asset } from '../publish/sep40' + +const TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015' +const NOW_MS = 1_760_000_400_000 +const TICK = 1_760_000_400 + +const XLM: Sep40Asset = { kind: 'other', symbol: 'XLM' } +// Derived, not copied from a document: the same expression the publisher uses, +// reached from the issuer the pair carries. +const USDC_SAC_ID = new Asset('USDC', configMock.pair.assetB.issuer!).contractId(TESTNET_PASSPHRASE) +const USDC: Sep40Asset = { kind: 'stellar', contractId: USDC_SAC_ID } + +interface FeedOptions { + base?: Sep40Asset + decimals?: number + resolution?: number + published?: Record + reportError?: Error + parametersError?: Error +} + +/** A feed that records instead of acting. */ +function fakeFeed(options: FeedOptions = {}) { + const calls = { + parameters: 0, + lastPrice: [] as Sep40Asset[], + report: [] as { asset: Sep40Asset; price: bigint; timestamp: number; venues: string[] }[], + } + const key = (asset: Sep40Asset) => (asset.kind === 'stellar' ? `stellar:${asset.contractId}` : `other:${asset.symbol}`) + const feed: Sep40Feed = { + network: 'testnet', + contractId: 'feed', + async parameters() { + calls.parameters++ + if (options.parametersError) throw options.parametersError + return { + base: options.base ?? XLM, + decimals: options.decimals ?? 14, + resolution: options.resolution ?? 60, + } + }, + async lastPrice(asset) { + calls.lastPrice.push(asset) + const entry = options.published?.[key(asset)] + return entry ?? null + }, + async report(input) { + calls.report.push(input) + if (options.reportError) throw options.reportError + return { txHash: 'txhash-1', ledger: 42 } + }, + } + return { feed, calls } +} + +function fakeDeps( + feed: Sep40Feed, + aggregateValue: Partial = {}, + venues: string[] = ['SDEX', 'aquarius_amm'] +) { + const seen = { aggregate: [] as [string, string][], venues: [] as [string, string][], records: [] as any[] } + const deps: PublicationDeps = { + feed, + pairs: [configMock.pair], + async aggregate(pairKey, network) { + seen.aggregate.push([pairKey, network]) + return { price: 0.05, stale: false, lastTradeAgeSeconds: 5, ...aggregateValue } + }, + async venues(pairKey, network) { + seen.venues.push([pairKey, network]) + return venues + }, + async record(row) { + seen.records.push(row) + }, + } + return { deps, seen } +} + +describe('runPublicationCycle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('quotes the pair against the feed’s base, inverting the stored direction', () => { + // Lens stores 0.05 USDC per XLM. The feed's base is XLM, so what a consumer + // asks for is the price of USDC in XLM: 20, not 0.05 — and the asset the + // report is filed under is the USDC contract, not "USDC". + const { feed, calls } = fakeFeed({ base: XLM }) + const { deps } = fakeDeps(feed) + return runPublicationCycle('testnet', deps, NOW_MS).then(() => { + expect(calls.report).toHaveLength(1) + const report = calls.report[0] + expect(report.asset).toEqual(USDC) + expect(report.price).toBe(2_000_000_000_000_000n) // 20 at 14 decimals + expect(report.timestamp).toBe(TICK) + expect(report.venues).toEqual(['SDEX', 'aquarius_amm']) + }) + }) + + it('publishes the stored direction when the base is the second leg', () => { + const { feed, calls } = fakeFeed({ base: USDC }) + const { deps } = fakeDeps(feed) + return runPublicationCycle('testnet', deps, NOW_MS).then(() => { + expect(calls.report).toHaveLength(1) + expect(calls.report[0].asset).toEqual(XLM) + expect(calls.report[0].price).toBe(5_000_000_000_000n) // 0.05 at 14 decimals + }) + }) + + it('writes nothing for a pair that does not contain the feed’s base', async () => { + const { feed, calls } = fakeFeed({ base: { kind: 'other', symbol: 'BTC' } }) + const { deps } = fakeDeps(feed) + const report = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.report).toHaveLength(0) + expect(calls.lastPrice).toHaveLength(0) + expect(report.outcomes[0].decision.publish).toBe(false) + if (!report.outcomes[0].decision.publish) { + expect(report.outcomes[0].decision.guard).toBe('base_not_in_pair') + } + }) + + it('does not touch the chain at all for a stale aggregate', async () => { + const { feed, calls } = fakeFeed() + const { deps } = fakeDeps(feed, { stale: true }) + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.lastPrice).toHaveLength(0) + expect(calls.report).toHaveLength(0) + expect(cycle.published).toHaveLength(0) + expect(cycle.outcomes[0].decision.guard).toBe('stale_aggregate') + }) + + it('does not touch the chain for an aggregate with no venue behind it', async () => { + const { feed, calls } = fakeFeed() + const { deps } = fakeDeps(feed, {}, []) + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.lastPrice).toHaveLength(0) + expect(calls.report).toHaveLength(0) + expect(cycle.outcomes[0].decision.guard).toBe('no_venue') + }) + + it('refuses a venue name the contract cannot store, rather than sending a doomed write', async () => { + const { feed, calls } = fakeFeed() + const { deps } = fakeDeps(feed, {}, ['a_venue_name_that_is_definitely_longer_than_thirty_two_bytes']) + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.report).toHaveLength(0) + expect(cycle.outcomes[0].decision.guard).toBe('venue_provenance') + }) + + it('asks for the aggregate and the venues on the network it is publishing to', async () => { + const { feed } = fakeFeed() + const { deps, seen } = fakeDeps(feed) + await runPublicationCycle('testnet', deps, NOW_MS) + expect(seen.aggregate[0][1]).toBe('testnet') + expect(seen.venues[0][1]).toBe('testnet') + expect(seen.records[0].network).toBe('testnet') + }) + + it('publishes on deviation from the value already on chain', async () => { + // The feed holds 10 XLM per USDC; the aggregate says 1/0.06 = 16.666…, + // which is a 66% move. The reference is the chain, not a local variable, so + // two publisher instances would make the same call here. + const { feed, calls } = fakeFeed({ + published: { [`stellar:${USDC_SAC_ID}`]: { price: 1_000_000_000_000_000n, timestamp: TICK - 300 } }, + }) + const { deps } = fakeDeps(feed, { price: 0.06 }) + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.report).toHaveLength(1) + expect(calls.report[0].price).toBe(1_666_666_666_666_667n) // 16.66666666666667 at 14 decimals + expect(cycle.outcomes[0].decision.publish).toBe(true) + if (cycle.outcomes[0].decision.publish) { + expect(cycle.outcomes[0].decision.trigger).toBe('deviation') + } + }) + + it('does not write when the aggregate matches what the feed already holds', async () => { + // 20 XLM per USDC is exactly what 0.05 USDC per XLM inverts to, and the + // feed is only 300 s old: inside the max interval and under the threshold. + const { feed, calls } = fakeFeed({ + published: { [`stellar:${USDC_SAC_ID}`]: { price: 2_000_000_000_000_000n, timestamp: TICK - 300 } }, + }) + const { deps } = fakeDeps(feed, { price: 0.05 }) + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.report).toHaveLength(0) + expect(cycle.outcomes[0].decision.guard).toBe('below_deviation') + }) + + it('records provenance on every published row and calls it published only after the write', async () => { + const { feed } = fakeFeed() + const { deps, seen } = fakeDeps(feed) + await runPublicationCycle('testnet', deps, NOW_MS) + expect(seen.records).toHaveLength(1) + const row = seen.records[0] + expect(row.state).toBe('published') + expect(row.trigger).toBe('first_report') + expect(row.venues).toBe('SDEX,aquarius_amm') + expect(row.sourceCount).toBe(2) + expect(row.quotedAsset).toBe(USDC_SAC_ID) + expect(row.baseAsset).toBe('other:XLM') + expect(row.price).toBe(20) + expect(row.scaledPrice).toBe('2000000000000000') + expect(row.txHash).toBe('txhash-1') + }) + + it('audits a failed write with the number it tried to publish', async () => { + const { feed, calls } = fakeFeed({ reportError: new FeedWriteError('contract rejected the report: NotNewer') }) + const { deps, seen } = fakeDeps(feed) + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(calls.report).toHaveLength(1) + expect(cycle.failed).toEqual([configMock.pair.pairKey]) + expect(seen.records).toHaveLength(1) + expect(seen.records[0].state).toBe('failed') + expect(seen.records[0].errorMessage).toContain('NotNewer') + }) + + it('aborts the whole cycle when the feed’s parameters cannot be read', async () => { + // Fail closed: with no decimals there is no correct scale to publish at, + // and the outer loop is the right place to back off. + const { feed, calls } = fakeFeed({ parametersError: new FeedUnavailableError('rpc is down') }) + const { deps } = fakeDeps(feed) + await expect(runPublicationCycle('testnet', deps, NOW_MS)).rejects.toThrow(FeedUnavailableError) + expect(calls.report).toHaveLength(0) + }) + + it('keeps going after one pair fails so a bad pair cannot silence the feed', async () => { + const { feed, calls } = fakeFeed() + const { deps, seen } = fakeDeps(feed) + deps.pairs = [ + { assetA: { code: 'BAD', issuer: 'not-an-account' }, assetB: { code: 'USD', issuer: null }, pairKey: 'BAD/USD' }, + configMock.pair, + ] + const cycle = await runPublicationCycle('testnet', deps, NOW_MS) + expect(cycle.outcomes[0].decision.guard).toBe('asset_identity') + expect(calls.report).toHaveLength(1) + expect(cycle.published).toEqual([configMock.pair.pairKey]) + expect(seen.records).toHaveLength(1) + }) +}) diff --git a/src/__tests__/sep40Trigger.test.ts b/src/__tests__/sep40Trigger.test.ts new file mode 100644 index 00000000..e775465a --- /dev/null +++ b/src/__tests__/sep40Trigger.test.ts @@ -0,0 +1,250 @@ +/** + * The publication policy (#222): deviation, maximum interval, and the guards + * that outrank both. + * + * All of it is arithmetic on injected values, so no clock, chain or database is + * involved — which is also the point: the rule "a stale aggregate is never + * published" is asserted here as a property of the decision function, not as a + * hope about how the loop behaves on a Tuesday. + */ +import { describe, it, expect } from 'vitest' +import { + decidePublication, + deviationPercent, + evaluateGuards, + feedTimestamp, + type AggregateSnapshot, + type PublishedSnapshot, + type TriggerThresholds, +} from '../publish/trigger' + +const NOW_MS = 1_760_000_400_000 +const TICK = 1_760_000_400 // NOW_MS floored to a 60-second tick + +const thresholds: TriggerThresholds = { + deviationPct: 0.5, + maxIntervalMs: 900_000, + minWriteIntervalMs: 60_000, + maxAgeSeconds: 120, + resolutionSeconds: 60, +} + +function aggregate(overrides: Partial = {}): AggregateSnapshot { + return { + pairKey: 'XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + price: 0.05, + venues: ['SDEX', 'aquarius_amm'], + stale: false, + lastTradeAgeSeconds: 5, + ...overrides, + } +} + +/** On-chain reference, by default a comfortable 600 s ago: past the rate floor, + * inside the max interval, so a test that wants a trigger gets to choose it. */ +function published(price = 0.05, secondsAgo = 600): PublishedSnapshot { + return { price, timestamp: TICK - secondsAgo } +} + +describe('feedTimestamp', () => { + it('is SEP-40’s floor(unix_now()/resolution)*resolution', () => { + expect(feedTimestamp(NOW_MS, 60)).toBe(TICK) + expect(feedTimestamp(1_760_000_459_999, 60)).toBe(1_760_000_400) + expect(feedTimestamp(1_760_000_459_999, 300)).toBe(1_760_000_400) + expect(feedTimestamp(1_760_000_000_000, 1)).toBe(1_760_000_000) + }) + + it('throws rather than dividing by a resolution the feed did not declare', () => { + expect(() => feedTimestamp(NOW_MS, 0)).toThrow(/positive whole number/) + }) +}) + +describe('deviationPercent', () => { + it('measures the move relative to the value being replaced', () => { + expect(deviationPercent(100, 101)).toBeCloseTo(1, 10) + expect(deviationPercent(100, 99)).toBeCloseTo(1, 10) + expect(deviationPercent(0.04, 0.05)).toBeCloseTo(25, 10) + }) + + it('reads an uncomparable reference as infinite, never as "no movement"', () => { + // A published zero would otherwise look like a price that never changes, + // and the feed would stay silent forever. + expect(deviationPercent(0, 0.05)).toBe(Infinity) + expect(deviationPercent(NaN, 0.05)).toBe(Infinity) + }) +}) + +describe('guards outrank triggers', () => { + it('does not publish a stale aggregate, even on a first report', () => { + const decision = decidePublication(aggregate({ stale: true }), null, thresholds, NOW_MS) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('stale_aggregate') + }) + + it('does not publish a stale aggregate just because the interval elapsed', () => { + // The one combination that would otherwise turn "stale" into a write: the + // max-interval trigger with nothing but old data to say. + const decision = decidePublication(aggregate({ stale: true }), published(0.05, 5_000), thresholds, NOW_MS) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('stale_aggregate') + }) + + it('blocks an unsourced number before anything else is considered', () => { + const decision = decidePublication( + aggregate({ venues: [], stale: true }), + published(0.05, 5_000), + thresholds, + NOW_MS + ) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('no_venue') + }) + + it('blocks a price that is not positive', () => { + for (const price of [0, -3.5, Number.NaN]) { + const decision = decidePublication(aggregate({ price }), null, thresholds, NOW_MS) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('invalid_price') + } + }) + + it('blocks a pair with no observation at all on this network', () => { + const decision = decidePublication(aggregate({ lastTradeAgeSeconds: null }), null, thresholds, NOW_MS) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('no_observation') + }) + + it('blocks an aggregate older than the feed allows, even when the aggregator calls it fresh', () => { + // The API's own stale flag trips at 300 s; the feed is deliberately + // stricter, so 200 s passes the API and fails here. + const decision = decidePublication(aggregate({ lastTradeAgeSeconds: 200 }), published(0.04, 600), thresholds, NOW_MS) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('aggregate_too_old') + }) + + it('is reusable on its own, for a caller that wants to skip without reading the chain', () => { + expect(evaluateGuards(aggregate({ stale: true }), thresholds, TICK)?.guard).toBe('stale_aggregate') + expect(evaluateGuards(aggregate(), thresholds, TICK)).toBeNull() + }) +}) + +describe('triggers', () => { + it('publishes the first report for an asset the feed does not hold', () => { + const decision = decidePublication(aggregate(), null, thresholds, NOW_MS) + expect(decision.publish).toBe(true) + if (decision.publish) { + expect(decision.trigger).toBe('first_report') + expect(decision.feedTimestamp).toBe(TICK) + expect(decision.deviationPct).toBeNull() + } + }) + + it('publishes when the price moves past the deviation threshold', () => { + const decision = decidePublication(aggregate({ price: 0.051 }), published(0.05), thresholds, NOW_MS) + expect(decision.publish).toBe(true) + if (decision.publish) { + expect(decision.trigger).toBe('deviation') + expect(decision.deviationPct).toBeCloseTo(2, 8) + expect(decision.publishedAgeMs).toBe(600_000) + } + }) + + it('publishes without movement once the feed is at its maximum age', () => { + const decision = decidePublication(aggregate({ price: 0.05 }), published(0.05, 1_000), thresholds, NOW_MS) + expect(decision.publish).toBe(true) + if (decision.publish) expect(decision.trigger).toBe('max_interval') + }) + + it('stays silent inside both windows', () => { + const decision = decidePublication(aggregate({ price: 0.05001 }), published(0.05, 300), thresholds, NOW_MS) + expect(decision.publish).toBe(false) + if (!decision.publish) expect(decision.guard).toBe('below_deviation') + }) + + it('takes the deviation branch when both windows are exceeded', () => { + const decision = decidePublication(aggregate({ price: 0.06 }), published(0.05, 1_000), thresholds, NOW_MS) + expect(decision.publish).toBe(true) + if (decision.publish) expect(decision.trigger).toBe('deviation') + }) + + it('republishes when what is on chain cannot be compared', () => { + const decision = decidePublication(aggregate({ price: 0.05 }), published(0, 300), thresholds, NOW_MS) + expect(decision.publish).toBe(true) + if (decision.publish) expect(decision.trigger).toBe('deviation') + }) +}) + +describe('write rate limiting', () => { + it('vetoed a deviation that arrived inside the minimum write interval', () => { + const decision = decidePublication( + aggregate({ price: 0.06 }), + published(0.05, 30), + thresholds, + NOW_MS + ) + expect(decision.publish).toBe(false) + if (!decision.publish) { + expect(decision.guard).toBe('min_write_interval') + // The reason says what was suppressed, so an operator reading the log can + // tell "nothing happened" from "something happened and was rate-limited". + expect(decision.suppressedBy).toBe('deviation') + } + }) + + it('vetoed a max-interval refresh inside the minimum write interval', () => { + const decision = decidePublication( + aggregate(), + { price: 0.05, timestamp: TICK - 30 }, + { ...thresholds, maxIntervalMs: 10_000 }, + NOW_MS + ) + expect(decision.publish).toBe(false) + if (!decision.publish) { + expect(decision.guard).toBe('min_write_interval') + expect(decision.suppressedBy).toBe('max_interval') + } + }) + + it('does not send a write the contract would reject as NotNewer', () => { + // Two polls inside the same tick: the second has the same trimmed + // timestamp, and the contract requires a strictly newer one. + const decision = decidePublication( + aggregate({ price: 0.06 }), + published(0.05, 0), + thresholds, + NOW_MS + ) + expect(decision.publish).toBe(false) + if (!decision.publish) { + expect(decision.guard).toBe('tick_not_advanced') + expect(decision.suppressedBy).toBe('deviation') + } + }) + + it('never writes twice inside a tick even when both branches would allow it', () => { + const first = decidePublication(aggregate({ price: 0.06 }), published(0.05, 120), thresholds, NOW_MS) + expect(first.publish).toBe(true) + // Same instant, same tick, larger move: rate-limited, not a second write. + const second = decidePublication(aggregate({ price: 0.08 }), published(0.06, 0), thresholds, NOW_MS) + expect(second.publish).toBe(false) + }) + + it('publishes at most once per resolution tick across a simulated hour of polling', () => { + // 30-second polls for one hour with a price that jitters above the + // threshold on every sample. Expected writes: the deviation-driven ones, + // bounded by the 60 s tick — not 120. + let writes = 0 + let onChain: PublishedSnapshot | null = { price: 0.05, timestamp: TICK - 600 } + let price = 0.05 + for (let offset = 0; offset < 3_600_000; offset += 30_000) { + price = price === 0.05 ? 0.052 : 0.05 + const decision = decidePublication(aggregate({ price }), onChain, thresholds, NOW_MS + offset) + if (decision.publish) { + writes++ + onChain = { price, timestamp: decision.feedTimestamp } + } + } + expect(writes).toBeLessThanOrEqual(60) + expect(writes).toBeGreaterThan(0) + }) +}) diff --git a/src/config.ts b/src/config.ts index 35c54518..52781af0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,6 +42,23 @@ export interface NetworkConfig { /** Reflector oracle contract ID for this network. */ reflectorContractId: string } + /** + * The SEP-40 publisher — the one direction that writes. `oracle` above is + * Lens reading somebody else's feed; this is Lens pushing its own aggregate + * on chain for contracts to read. + */ + publisher: { + /** Whether Lens publishes to a SEP-40 feed on this network. */ + enabled: boolean + /** Deployed SEP-40 oracle contract the aggregate is pushed into. */ + contractId: string + /** + * Secret key of the account authorized on the contract as publisher. Never + * committed; see docs/sep40-oracle-publisher.md for how it is funded and + * rotated. Unset means the publisher cannot sign, so publication stays off. + */ + secretKey?: string + } /** Watched asset pairs for this network. */ pairs: WatchedPair[] facilitator: { @@ -69,6 +86,21 @@ function makePairKey(a: AssetId, b: AssetId): string { return [aStr, bStr].sort().join('/') } +/** + * Read a positive numeric env var, falling back when it is unset, blank, not a + * number, or not positive. + * + * The publisher thresholds below are used as divisors, comparison bases and + * `setInterval` delays. A typo'd `SEP40_MAX_INTERVAL_MS=abc` would otherwise + * arrive there as `NaN`, and `NaN` comparisons are false — which reads as + * "never trigger", silently turning the feed off. Refusing the value and using + * the documented default keeps a bad env var loud instead of inert. + */ +function positiveNumber(raw: string | undefined, fallback: number): number { + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + /** * Parse a comma-separated hostname allowlist into a normalized, de-duplicated * list. Empty entries and surrounding whitespace are dropped; hostnames are @@ -204,6 +236,39 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { (process.env[`REFLECTOR_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && isUsableContractId(reflectorContractId, `REFLECTOR_CONTRACT_ID_${suffix}`) + // ── SEP-40 publisher ────────────────────────────────────────────────────── + // Off unless a contract is deployed AND a key is provided. There is no + // default contract id: the feed is deployed by the operator (see + // oracle/contract/), and a guessed id would push Lens's aggregate into + // somebody else's contract — or nowhere. `isUsableContractId` rejects a + // malformed id the same way every other contract address in this file does. + const sep40ContractId = + process.env[`SEP40_CONTRACT_ID_${suffix}`] || + process.env.SEP40_CONTRACT_ID || + '' + + const sep40SecretKey = + process.env[`SEP40_PUBLISHER_SECRET_KEY_${suffix}`] || + (network === 'testnet' ? process.env.SEP40_PUBLISHER_SECRET_KEY : undefined) + + // A key with no contract, or a contract with no key, is an incomplete + // deployment rather than a mistake worth crashing on — publication stays off + // and says so. + const publisherEnabled = + (process.env[`SEP40_ENABLED_${suffix}`] ?? 'false').toLowerCase() !== 'false' && + isUsableContractId(sep40ContractId, `SEP40_CONTRACT_ID_${suffix}`) && + Boolean(sep40SecretKey) + + if ( + (process.env[`SEP40_ENABLED_${suffix}`] ?? '').toLowerCase() === 'true' && + !publisherEnabled + ) { + log.warn( + { network, envVar: `SEP40_ENABLED_${suffix}`, contractId: sep40ContractId || undefined }, + `SEP40_ENABLED_${suffix}=true but publication is disabled on ${network}: it needs a valid SEP40_CONTRACT_ID_${suffix} and a SEP40_PUBLISHER_SECRET_KEY_${suffix}` + ) + } + // ── Watched pairs ───────────────────────────────────────────────────────── // Mainnet gets a default for the same reason every field above does: turning // it on should not require knowing an issuer address by heart. USDC/XLM is @@ -244,6 +309,11 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { apiUrl: aquariusApiUrl, }, oracle: { enabled: oracleEnabled, reflectorContractId }, + publisher: { + enabled: publisherEnabled, + contractId: sep40ContractId, + secretKey: sep40SecretKey, + }, pairs: parseWatchedPairs(rawPairs), facilitator: { secretKey: facilitatorSecretKey, feeStroops: facilitatorFeeStroops }, } @@ -267,6 +337,35 @@ const globalConfig = { sdexPageSize: parseInt(process.env.SDEX_PAGE_SIZE ?? '200', 10), ammPageSize: parseInt(process.env.AMM_PAGE_SIZE ?? '200', 10), }, + /** + * When the publisher writes to the SEP-40 feed. + * + * Cost is the reason an oracle batches: a write per tick would put one + * transaction per pair per poll interval on chain, which is why every feed + * that charges for gas publishes on CHANGE. These are the three knobs that + * make that true, and the ceiling that keeps a bad change off chain: + * + * - `deviationPct` — publish when the aggregate has moved at least this + * much from the value already on chain. + * - `maxIntervalMs` — and publish even without movement once this long has + * passed, so a quiet market does not leave consumers + * reading a feed with no recent timestamp. + * - `minWriteIntervalMs` — the floor between two writes for the same asset, + * whatever triggered them. This is the rate limit: + * it bounds the transaction count during a fast market + * to one per floor, not one per sample. + * - `maxAgeSeconds` — the aggregate itself must be younger than this. + * Stricter than the API's own stale flag on purpose: + * the HTTP price can afford to be "as good as we have + * it", a price a contract settles against cannot. + */ + publisher: { + deviationPct: positiveNumber(process.env.SEP40_DEVIATION_PCT, 0.5), + maxIntervalMs: positiveNumber(process.env.SEP40_MAX_INTERVAL_MS, 15 * 60 * 1000), + minWriteIntervalMs: positiveNumber(process.env.SEP40_MIN_WRITE_INTERVAL_MS, 60 * 1000), + maxAgeSeconds: positiveNumber(process.env.SEP40_MAX_AGE_SECONDS, 120), + pollIntervalMs: positiveNumber(process.env.SEP40_POLL_INTERVAL_MS, 30 * 1000), + }, cache: { priceTtl: parseInt(process.env.PRICE_CACHE_TTL ?? '10', 10), // How long a network's GET /snapshot response is served from Redis before @@ -364,6 +463,11 @@ export const config = { redis: globalConfig.redis, api: globalConfig.api, indexer: globalConfig.indexer, + // WHEN to publish, shared by both networks. WHICH contract to publish to is + // per-network and lives at `getNetworkConfig(network).publisher` — there is + // deliberately no active-network `publisher` shortcut, because the two blocks + // are different things and one name for both would be read wrongly. + publisher: globalConfig.publisher, cache: globalConfig.cache, http: globalConfig.http, outbound: globalConfig.outbound, diff --git a/src/index.ts b/src/index.ts index 5cf4a54f..40a79890 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { execSync } from 'child_process' if (!process.env.DIRECT_DATABASE_URL && process.env.DATABASE_URL) { process.env.DIRECT_DATABASE_URL = process.env.DATABASE_URL } -import { config, type NetworkName } from './config' +import { config, getNetworkConfig, type NetworkName } from './config' import { getEnabledNetworks } from './network/enabledNetworks' import { createLogger } from './logger' import { redis } from './redis' @@ -25,6 +25,7 @@ import { } from './jobs/pricePointsPartitionJob' import { loadPersistedPairs, getActivePairs } from './pairsRegistry' import { markBootComplete } from './health' +import { startSep40Publisher } from './publish/publisher' const log = createLogger('startup') @@ -201,6 +202,17 @@ async function main() { restartIngester(adapter.name, network, () => startVenueAdapter(adapter, network)) } restartIngester('Snapshot', network, () => startSnapshotIngester(network)) + + // The publisher is the one loop in this process that writes to a chain, so + // it is started on its own per-network switch rather than alongside the + // readers: an operator who has deployed a feed but has not put a key in the + // environment gets an explicit "not started", not a crash loop. Same fault + // isolation as every ingester above. + if (getNetworkConfig(network).publisher.enabled) { + restartIngester('SEP-40 Publisher', network, () => startSep40Publisher(network)) + } else { + log.info({ network }, 'SEP-40 publication disabled; publisher not started') + } } log.info( diff --git a/src/publish/onchain.ts b/src/publish/onchain.ts new file mode 100644 index 00000000..ce43dc73 --- /dev/null +++ b/src/publish/onchain.ts @@ -0,0 +1,317 @@ +/** + * The on-chain half of the SEP-40 publisher (#222). + * + * `trigger.ts` decides *whether* to write; this module is the only place that + * actually talks to a contract. It is deliberately narrow: + * + * - three immutable reads (`base`, `decimals`, `resolution`) so Lens never + * guesses how a deployed feed is scaled or trimmed; + * - one mutable read (`lastprice`) which is the deviation reference; + * - one write (`report`). + * + * The whole surface sits behind the `Sep40Feed` interface, so the publisher loop + * is tested against a recording fake. This class itself talks to a deployed + * contract, so the suite that proves its other half is the Rust one in + * `oracle/contract` — the wire forms both sides agree on are pinned here by + * `src/__tests__/sep40Encoding.test.ts`. + * + * Reads FAIL CLOSED. If `base()`/`decimals()`/`resolution()` cannot be fetched, + * we do not fall back to "14 decimals is what Reflector uses" or to a + * hard-coded resolution: a price published at the wrong scale is not a slightly + * wrong price, it is a price off by orders of magnitude, and every consumer + * downstream trusts it because it came from a contract. + */ + +import { contract as StellarContract, Keypair, StrKey } from '@stellar/stellar-sdk' +import { getNetworkConfig, type NetworkName } from '../config' +import { createLogger } from '../logger' +import { + assetFromSpecArg, + assetToSpecArg, + type Sep40Asset, + type SpecUnion, +} from './sep40' + +const log = createLogger('sep40-feed') + +/** A raw `PriceData` as it comes off the contract. */ +export interface RawPriceData { + price: bigint + timestamp: number +} + +/** The three feed parameters a consumer must agree with before publishing. */ +export interface FeedParameters { + base: Sep40Asset + decimals: number + resolution: number +} + +/** Everything a report needs, already in the units the contract expects. */ +export interface ReportInput { + asset: Sep40Asset + /** Scaled by `decimals` — see `scalePrice` in sep40.ts. */ + price: bigint + /** Trimmed to `resolution` — SEP-40's `floor(unix_now()/resolution)*resolution`. */ + timestamp: number + /** Venue names behind the number; the contract derives `source_count` from this. */ + venues: string[] +} + +/** What the ledger gave back for a successful write. */ +export interface ReportReceipt { + /** Transaction hash, or null if the RPC did not echo one. */ + txHash: string | null + /** Ledger the report landed in, when known. */ + ledger: number | null +} + +/** + * The feed, as the publisher sees it. Implementations throw rather than return + * null when a read of the feed's *own parameters* fails; `lastPrice` returns + * null when the feed simply has nothing for that asset yet, which is what + * SEP-40 requires for an unquoted asset (`None`, not an error). + */ +export interface Sep40Feed { + readonly network: NetworkName + readonly contractId: string + parameters(): Promise + lastPrice(asset: Sep40Asset): Promise + report(input: ReportInput): Promise +} + +/** The feed could not be read, so nothing may be published. */ +export class FeedUnavailableError extends Error { + constructor(message: string) { + super(message) + this.name = 'FeedUnavailableError' + } +} + +/** The write was submitted and did not succeed. */ +export class FeedWriteError extends Error { + constructor(message: string) { + super(message) + this.name = 'FeedWriteError' + } +} + +type AssembledTransaction = StellarContract.AssembledTransaction + +/** What the spec-generated `Client` exposes for the calls this module makes. */ +interface FeedClient { + base(): Promise> + decimals(): Promise> + resolution(): Promise> + lastprice(args: { asset: SpecUnion }): Promise> + report(args: { + asset: SpecUnion + price: bigint + timestamp: number + venues: string[] + }): Promise> +} + +function requireContractId(contractId: string, network: NetworkName): string { + if (!StrKey.isValidContract(contractId)) { + throw new FeedUnavailableError( + `the configured SEP-40 contract id for ${network} is "${contractId}", which is not a C... contract address` + ) + } + return contractId +} + +function requireKeypair(secretKey: string | undefined, network: NetworkName): Keypair { + if (!secretKey) { + throw new FeedUnavailableError( + `no publisher secret key is configured for ${network}; the feed can be read but never written` + ) + } + try { + return Keypair.fromSecret(secretKey.trim()) + } catch { + throw new FeedUnavailableError( + `the configured publisher secret key for ${network} is not a valid Stellar secret key` + ) + } +} + +/** + * u64 decodes to a JS number when it is safe and to a bigint when it is not; a + * feed timestamp is a unix second, so either way it fits in a number. + */ +function toSeconds(value: bigint | number): number { + return Number(value) +} + +/** + * A feed backed by a deployed contract on `network`, driven by the SDK's + * spec-generated `Client`. The client is built lazily and only once: building it + * downloads the contract's WASM to read its spec, so a feed that is never polled + * costs no RPC calls. + */ +export class ContractSep40Feed implements Sep40Feed { + readonly network: NetworkName + readonly contractId: string + + private clientPromise: Promise | null = null + private parametersCache: FeedParameters | null = null + + constructor(network: NetworkName, contractId: string, private readonly secretKey?: string) { + this.network = network + this.contractId = requireContractId(contractId, network) + } + + private async client(): Promise { + if (!this.clientPromise) { + this.clientPromise = this.buildClient() + } + try { + return await this.clientPromise + } catch (err) { + // A failed construction must not be cached forever: the next poll should + // get another chance after an RPC hiccup or a redeploy. + this.clientPromise = null + throw err + } + } + + private async buildClient(): Promise { + const netConfig = getNetworkConfig(this.network) + const keypair = requireKeypair(this.secretKey, this.network) + const signer = StellarContract.basicNodeSigner(keypair, netConfig.network.passphrase) + try { + const client = await StellarContract.Client.from({ + contractId: this.contractId, + rpcUrl: netConfig.rpc.url, + networkPassphrase: netConfig.network.passphrase, + publicKey: keypair.publicKey(), + signTransaction: signer.signTransaction, + signAuthEntry: signer.signAuthEntry, + }) + return client as unknown as FeedClient + } catch (err) { + throw new FeedUnavailableError( + `could not load the SEP-40 feed at ${this.contractId} on ${this.network} (${netConfig.rpc.url}): ${(err as Error).message}` + ) + } + } + + /** + * `base`, `decimals` and `resolution` are fixed at `initialize` time, and + * SEP-40 tells consumers they can rely on that, so they are read once and + * kept for the life of the process. + */ + async parameters(): Promise { + if (this.parametersCache) return this.parametersCache + const client = await this.client() + let baseTx: AssembledTransaction + let decimalsTx: AssembledTransaction + let resolutionTx: AssembledTransaction + try { + ;[baseTx, decimalsTx, resolutionTx] = await Promise.all([ + client.base(), + client.decimals(), + client.resolution(), + ]) + } catch (err) { + throw new FeedUnavailableError( + `the feed at ${this.contractId} on ${this.network} did not answer base()/decimals()/resolution(): ${(err as Error).message}` + ) + } + + const decimals = Number(decimalsTx.result) + const resolution = Number(resolutionTx.result) + const base = assetFromSpecArg(baseTx.result) + if (!Number.isInteger(decimals) || decimals < 0) { + throw new FeedUnavailableError( + `the feed at ${this.contractId} on ${this.network} reported decimals=${decimals}` + ) + } + if (!Number.isInteger(resolution) || resolution <= 0) { + throw new FeedUnavailableError( + `the feed at ${this.contractId} on ${this.network} reported resolution=${resolution}` + ) + } + this.parametersCache = { base, decimals, resolution } + return this.parametersCache + } + + async lastPrice(asset: Sep40Asset): Promise { + const client = await this.client() + try { + const tx = await client.lastprice({ asset: assetToSpecArg(asset) }) + const raw = tx.result + if (!raw) return null + return { price: BigInt(raw.price), timestamp: toSeconds(raw.timestamp) } + } catch (err) { + throw new FeedUnavailableError( + `lastprice failed against ${this.contractId} on ${this.network} for ${asset.kind === 'stellar' ? asset.contractId : asset.symbol}: ${(err as Error).message}` + ) + } + } + + async report(input: ReportInput): Promise { + const client = await this.client() + const assetId = input.asset.kind === 'stellar' ? input.asset.contractId : input.asset.symbol + try { + const tx = await client.report({ + asset: assetToSpecArg(input.asset), + price: input.price, + timestamp: input.timestamp, + venues: input.venues, + }) + // `signAndSend` waits for the transaction to finalize, so a success here + // is a claim about the ledger — which is what makes the audit row safe to + // write as `published` rather than `sent`. + const sent = await tx.signAndSend() + const response = sent.getTransactionResponse + // `ledger` only exists on the responses the RPC actually found; a + // NOT_FOUND shape has none, and the union has to be narrowed for TS (and + // for the operator reading the message) to agree on that. + const ledger = response && 'ledger' in response ? Number(response.ledger) : null + if (response && String(response.status) !== 'SUCCESS') { + throw new FeedWriteError( + `report for ${assetId} at ${input.timestamp} finalized as ${response.status} in ledger ${ledger ?? 'unknown'} (tx ${sent.sendTransactionResponse?.hash ?? 'unknown'}); re-run the diagnostic events against Soroban RPC with that hash to see why` + ) + } + const receipt: ReportReceipt = { + txHash: sent.sendTransactionResponse?.hash ?? null, + ledger, + } + log.info({ contract: this.contractId, network: this.network, ...receipt }, 'SEP-40 report finalized') + return receipt + } catch (err) { + if (err instanceof FeedWriteError) throw err + throw new FeedWriteError( + `report to ${this.contractId} on ${this.network} failed for ${assetId} at ${input.timestamp}: ${(err as Error).message}` + ) + } + } +} + +// ── Feed registry ──────────────────────────────────────────────────────────── + +const feeds = new Map() + +/** + * The feed for `network`, or null when publication is not configured for it. + * `config` already folds "enabled" together with "has a usable contract id and + * a key", so null here means the operator has not finished wiring this network + * up — a supported state, not an error. + */ +export function getSep40Feed(network: NetworkName): Sep40Feed | null { + const publisher = getNetworkConfig(network).publisher + if (!publisher.enabled) return null + const existing = feeds.get(network) + if (existing && existing.contractId === publisher.contractId) return existing + const feed = new ContractSep40Feed(network, publisher.contractId, publisher.secretKey) + feeds.set(network, feed) + log.info({ network, contract: publisher.contractId }, 'SEP-40 feed ready') + return feed +} + +/** Test hook: forget cached feeds so a suite can point at another contract. */ +export function _resetFeeds(): void { + feeds.clear() +} diff --git a/src/publish/publisher.ts b/src/publish/publisher.ts new file mode 100644 index 00000000..ffbe230a --- /dev/null +++ b/src/publish/publisher.ts @@ -0,0 +1,459 @@ +/** + * The SEP-40 publisher loop (#222). + * + * This is the piece that turns Lens from an oracle consumer into an oracle + * publisher: read our own aggregate, compare it with what the feed already + * holds, and — only when `trigger.ts` says the move justifies a transaction — + * write it. It runs once per enabled network, exactly like the ingest loops it + * sits next to, and it is the only code in Lens that signs a transaction. + * + * Three structural choices worth stating, because they are the ones a reviewer + * should push on: + * + * **Everything the loop depends on is injected.** `runPublicationCycle` takes a + * `PublicationDeps` — the feed, the aggregate source, the venue source, the + * audit sink — so the whole policy is exercised in tests against recording + * fakes, including "the chain was never touched". Production wires the real + * ones; there is no separate mock mode. + * + * **The feed's own parameters decide the math.** `base`, `decimals` and + * `resolution` are read from the deployed contract, never from an env var, so a + * mis-declared scale cannot produce a price off by four orders of magnitude. If + * those reads fail, the cycle aborts before any pair is attempted. + * + * **Which leg is published is derived, not configured.** SEP-40 quotes every + * asset against ONE base, while Lens stores a pair as `price = assetB per + * assetA`. The base tells us which leg of the pair is quoted and whether the + * number has to be inverted; a pair whose legs are neither of them is skipped + * with a reason, because publishing it would put a ratio under the wrong + * label. + */ + +import { activeNetwork, config, getNetworkConfig, type NetworkName } from '../config' +import { getAggregatedPrice } from '../aggregator/vwap' +import { pgPool, prisma } from '../db' +import { sleep } from '../ingest/venues/runner' +import { createLogger } from '../logger' +import type { WatchedPair } from '../types' +import { + Sep40EncodingError, + assetKey, + scalePrice, + toSep40Asset, + unscalePrice, + venuesToSpecArg, + type Sep40Asset, +} from './sep40' +import { + decidePublication, + evaluateGuards, + feedTimestamp, + type AggregateSnapshot, + type PublishedSnapshot, + type TriggerDecision, + type TriggerThresholds, +} from './trigger' +import { + FeedUnavailableError, + FeedWriteError, + getSep40Feed, + type Sep40Feed, +} from './onchain' + +const log = createLogger('sep40-publisher') + +/** + * How far back a venue counts as "behind this number". + * + * One hour is not arbitrary: it is the window the aggregator's own `sources` + * count uses, and any aggregate that clears the freshness gate (`maxAgeSeconds` + * defaults to 120) has trades well inside it. Widening the window would let a + * venue that has been dead all day keep signing the provenance of a live price. + */ +const VENUE_WINDOW_SECONDS = 3600 + +/** The slice of the aggregate the publisher needs — keeps the fake small. */ +export interface AggregateSource { + price: number + stale: boolean + lastTradeAgeSeconds: number | null +} + +/** An audit row, in the shape `OraclePublication` stores. */ +export interface PublicationRecord { + network: NetworkName + pairKey: string + quotedAsset: string + baseAsset: string + state: 'published' | 'failed' + trigger: string | null + price: number | null + scaledPrice: string | null + decimals: number | null + feedTimestamp: number | null + resolution: number | null + venues: string | null + sourceCount: number | null + deviationPct: number | null + reason: string + txHash?: string | null + errorMessage?: string | null +} + +export interface PublicationDeps { + feed: Sep40Feed + pairs: readonly WatchedPair[] + aggregate(pairKey: string, network: NetworkName): Promise + venues(pairKey: string, network: NetworkName): Promise + record(row: PublicationRecord): Promise +} + +/** One pair's outcome in a cycle, kept so tests can assert without a log grep. */ +export interface PairOutcome { + pairKey: string + /** Present when the pair is quotable against the feed's base. */ + quotedAsset?: string + decision: TriggerDecision | { publish: false; guard: string; reason: string } +} + +export interface CycleReport { + network: NetworkName + base: string + decimals: number + resolution: number + outcomes: PairOutcome[] + published: string[] + failed: string[] +} + +/** + * Which leg of `pair` this feed quotes, and the price in base terms. + * + * Lens stores `price` as units of assetB per assetA. SEP-40's `PriceData` is + * units of BASE per QUOTED asset, so: + * base == assetB → the stored number is already base-per-assetA; quote assetA. + * base == assetA → the stored number must be inverted; quote assetB. + */ +export function orientAgainstBase( + pair: WatchedPair, + base: Sep40Asset, + network: NetworkName +): { quoted: Sep40Asset; inverted: boolean } | { skip: string } { + const assetA = toSep40Asset(pair.assetA, network) + const assetB = toSep40Asset(pair.assetB, network) + const baseKey = assetKey(base) + if (baseKey === assetKey(assetB)) return { quoted: assetA, inverted: false } + if (baseKey === assetKey(assetA)) return { quoted: assetB, inverted: true } + return { + skip: `${pair.pairKey}: the feed's base is ${baseKey}, which is neither leg of this pair — there is no correct label for this number on this feed`, + } +} + +/** `price` in the direction the feed wants, from the direction we store it in. */ +export function priceForFeed(storedPrice: number, inverted: boolean): number { + if (!inverted) return storedPrice + if (storedPrice <= 0 || !Number.isFinite(storedPrice)) return 0 + // Inverting is a division, and a division is where a zero sneaks through as + // Infinity and then fails the i128 bound far away from here. Returning 0 + // routes it to the `invalid_price` guard, which names the pair. + return 1 / storedPrice +} + +function thresholdsFor(resolutionSeconds: number): TriggerThresholds { + // Global, not per-network: the thresholds describe how Lens behaves, and an + // operator tuning mainnet's write rate almost certainly means testnet's too. + // The per-network half is `publisher.enabled` / contract id / key. + return { + deviationPct: config.publisher.deviationPct, + maxIntervalMs: config.publisher.maxIntervalMs, + minWriteIntervalMs: config.publisher.minWriteIntervalMs, + maxAgeSeconds: config.publisher.maxAgeSeconds, + resolutionSeconds, + } +} + +/** + * One pass over every watched pair on `network`. + * + * A guard that stops a write is a `continue`, never a throw: one pair with a + * bad issuer must not silence the other feeds. A feed we cannot READ is + * different — `FeedUnavailableError` propagates, because "we could not check + * what is already there" means we cannot honour the deviation or monotonicity + * promises either, and the outer loop should back off rather than churn. + */ +export async function runPublicationCycle( + network: NetworkName, + deps: PublicationDeps, + nowMs = Date.now() +): Promise { + const params = await deps.feed.parameters() + const baseKey = assetKey(params.base) + const thresholds = thresholdsFor(params.resolution) + + const report: CycleReport = { + network, + base: baseKey, + decimals: params.decimals, + resolution: params.resolution, + outcomes: [], + published: [], + failed: [], + } + + for (const pair of deps.pairs) { + const quotedLabel = (asset: Sep40Asset) => + asset.kind === 'stellar' ? asset.contractId : asset.symbol + + let orientation: ReturnType + try { + orientation = orientAgainstBase(pair, params.base, network) + } catch (err) { + if (err instanceof FeedUnavailableError) throw err + report.outcomes.push({ + pairKey: pair.pairKey, + decision: blocked( + 'asset_identity', + err instanceof Sep40EncodingError ? err.message : `${pair.pairKey}: ${(err as Error).message}` + ), + }) + continue + } + + if ('skip' in orientation) { + report.outcomes.push({ pairKey: pair.pairKey, decision: blocked('base_not_in_pair', orientation.skip) }) + log.info(orientation.skip) + continue + } + + const quotedAsset = quotedLabel(orientation.quoted) + try { + const [aggregate, venueNames] = await Promise.all([ + deps.aggregate(pair.pairKey, network), + deps.venues(pair.pairKey, network), + ]) + + // Validate provenance before the decision, so a venue list the contract + // would reject shows up as a blocked pair and not as a failed write. An + // EMPTY list is not an encoding failure — it is the `no_venue` guard's + // case, with its own code and its own sentence — so it goes through to + // the decision instead of being renamed here. + let venues: string[] + try { + venues = venueNames.length === 0 ? [] : venuesToSpecArg(venueNames) + } catch (err) { + report.outcomes.push({ + pairKey: pair.pairKey, + quotedAsset, + decision: blocked('venue_provenance', (err as Sep40EncodingError).message), + }) + continue + } + + const snapshot: AggregateSnapshot = { + pairKey: pair.pairKey, + price: priceForFeed(aggregate.price, orientation.inverted), + venues, + stale: aggregate.stale, + lastTradeAgeSeconds: aggregate.lastTradeAgeSeconds, + } + + // Staleness and provenance are checked BEFORE the chain is read. A stale + // aggregate needs no comparison to decide anything — it is simply not + // publishable — so spending an RPC call on it would buy nothing, and the + // "never published" guarantee is easier to trust when the code path does + // not touch the network at all. + const tick = feedTimestamp(nowMs, params.resolution) + const guard = evaluateGuards(snapshot, thresholds, tick) + if (guard) { + report.outcomes.push({ pairKey: pair.pairKey, quotedAsset, decision: guard }) + log.debug({ pair: pair.pairKey, network, guard: guard.guard }, 'publication blocked before the feed was read') + continue + } + + const raw = await deps.feed.lastPrice(orientation.quoted) + const published: PublishedSnapshot | null = raw + ? { price: unscalePrice(raw.price, params.decimals), timestamp: raw.timestamp } + : null + + const decision = decidePublication(snapshot, published, thresholds, nowMs) + report.outcomes.push({ pairKey: pair.pairKey, quotedAsset, decision }) + + if (!decision.publish) { + log.debug({ pair: pair.pairKey, guard: decision.guard }, 'publication blocked') + continue + } + + const scaled = scalePrice(snapshot.price, params.decimals) + const receipt = await deps.feed.report({ + asset: orientation.quoted, + price: scaled, + timestamp: decision.feedTimestamp, + venues, + }) + + await deps.record({ + network, + pairKey: pair.pairKey, + quotedAsset, + baseAsset: baseKey, + state: 'published', + trigger: decision.trigger, + price: snapshot.price, + scaledPrice: scaled.toString(), + decimals: params.decimals, + feedTimestamp: decision.feedTimestamp, + resolution: params.resolution, + venues: venues.join(','), + sourceCount: venues.length, + deviationPct: decision.deviationPct, + reason: decision.reason, + txHash: receipt.txHash, + }) + report.published.push(pair.pairKey) + log.info({ pair: pair.pairKey, trigger: decision.trigger, tx: receipt.txHash }, decision.reason) + } catch (err) { + if (err instanceof FeedUnavailableError) throw err + const message = (err as Error).message + const guard = err instanceof FeedWriteError ? 'write_failed' : 'cycle_error' + report.failed.push(pair.pairKey) + report.outcomes.push({ pairKey: pair.pairKey, quotedAsset, decision: blocked(guard, message) }) + log.error({ pair: pair.pairKey, network, err }, 'publication attempt failed') + // A failed write is exactly the audit trail an operator needs: the number + // we meant to publish, the venues behind it, and why it did not land. + await deps.record({ + network, + pairKey: pair.pairKey, + quotedAsset, + baseAsset: baseKey, + state: 'failed', + trigger: null, + price: null, + scaledPrice: null, + decimals: params.decimals, + feedTimestamp: null, + resolution: params.resolution, + venues: null, + sourceCount: null, + deviationPct: null, + reason: message, + errorMessage: message.slice(0, 2000), + }).catch(recErr => log.error({ err: recErr }, 'could not record a failed publication')) + } + } + + return report +} + +function blocked(guard: string, reason: string) { + return { publish: false as const, guard, reason } +} + +// ── Production wiring ──────────────────────────────────────────────────────── + +/** + * Venues that produced rows for this pair on this network. + * + * `network` is in the WHERE clause, not implied: this table holds both chains, + * and a provenance list that leaked mainnet venues onto a testnet report would + * be the exact silent blending this issue's instructions call out. + */ +export async function queryVenues(pairKey: string, network: NetworkName): Promise { + const result = await pgPool.query( + `SELECT DISTINCT source + FROM price_points + WHERE pair_key = $1 AND network = $2 + AND timestamp > NOW() - ($3 * INTERVAL '1 second')`, + [pairKey, network, VENUE_WINDOW_SECONDS] + ) + return result.rows.map((row: { source: string }) => row.source).sort() +} + +async function recordPublication(row: PublicationRecord): Promise { + await prisma.oraclePublication.create({ + data: { + network: row.network, + pairKey: row.pairKey, + quotedAsset: row.quotedAsset, + baseAsset: row.baseAsset, + state: row.state, + trigger: row.trigger, + price: row.price, + scaledPrice: row.scaledPrice, + decimals: row.decimals, + feedTimestamp: row.feedTimestamp === null ? null : BigInt(row.feedTimestamp), + resolution: row.resolution, + venues: row.venues, + sourceCount: row.sourceCount, + deviationPct: row.deviationPct, + reason: row.reason, + txHash: row.txHash ?? null, + errorMessage: row.errorMessage ?? null, + }, + }) +} + +function productionDeps(network: NetworkName, feed: Sep40Feed): PublicationDeps { + return { + feed, + // The env-declared pairs for THIS network, not the shared runtime registry: + // the registry is a single list covering whichever network loaded it, and + // quoting a pair this process has no rows for on this chain is how a feed + // starts publishing a number that belongs to another network. + pairs: getNetworkConfig(network).pairs, + async aggregate(pairKey) { + const price = await getAggregatedPrice(pairKey, network) + return { price: price.price, stale: price.stale, lastTradeAgeSeconds: price.lastTradeAgeSeconds } + }, + venues: queryVenues, + record: recordPublication, + } +} + +/** + * The publisher loop for one network. Never resolves: like the venue adapters + * it runs beside, it sleeps between cycles and backs off on a failed cycle. + * Returns immediately (without starting anything) when publication is not + * configured for this network. + */ +export async function startSep40Publisher(network: NetworkName = activeNetwork): Promise { + const feed = getSep40Feed(network) + if (!feed) { + log.info({ network }, 'SEP-40 publication is not configured for this network — publisher not started') + return + } + + const pollIntervalMs = config.publisher.pollIntervalMs + log.info( + { + network, + contract: feed.contractId, + pollIntervalMs, + deviationPct: config.publisher.deviationPct, + maxIntervalMs: config.publisher.maxIntervalMs, + minWriteIntervalMs: config.publisher.minWriteIntervalMs, + maxAgeSeconds: config.publisher.maxAgeSeconds, + }, + 'starting SEP-40 publisher' + ) + + let consecutiveErrors = 0 + while (true) { + try { + const cycle = await runPublicationCycle(network, productionDeps(network, feed)) + consecutiveErrors = 0 + if (cycle.published.length > 0) { + log.info({ network, published: cycle.published }, 'publication cycle wrote') + } + } catch (err) { + consecutiveErrors++ + const backoffMs = Math.min(10_000 * Math.pow(2, consecutiveErrors - 1), 60_000) + log.error( + { network, attempt: consecutiveErrors, backoffMs, err }, + 'SEP-40 publication cycle failed; nothing was written on this pass, retrying' + ) + await sleep(backoffMs) + continue + } + await sleep(pollIntervalMs) + } +} diff --git a/src/publish/sep40.ts b/src/publish/sep40.ts new file mode 100644 index 00000000..2b69b96e --- /dev/null +++ b/src/publish/sep40.ts @@ -0,0 +1,279 @@ +/** + * SEP-40 wire format (#222). + * + * Everything in this file is pure: it turns a Lens asset, price and timestamp + * into exactly the values the on-chain feed accepts, and nothing it writes here + * touches the network or the database. That matters because the failure mode of + * an oracle is not "the call failed" — it is "the call succeeded and the number + * is wrong by four orders of magnitude". Scale, trimming and asset identity are + * decided once, here, and tested. + * + * SEP-40 § Design Rationale defines the two rules this file implements: + * - "Price is encoded as an `i128` number where last N digits designate the + * fractional part of the price stored with precision defined by + * `decimals()` … the actual price can be calculated as `price/10^decimals`" + * - "`timestamp` refers to Unix Timestamp calculated as + * `floor(unix_now()/resolution)*resolution`" + */ + +import { Asset as StellarAsset, Address, StrKey, xdr } from '@stellar/stellar-sdk' +import { getNetworkConfig, type NetworkName } from '../config' +import type { AssetId } from '../types' + +/** + * Soroban's `ScSymbol` is capped at 32 bytes on the wire, and a symbol over the + * cap is a decode error at the ledger, not a validation error here. Checked at + * the boundary so an operator gets "venue name X is too long" instead of an + * opaque `tx failed`. + */ +export const MAX_SYMBOL_LENGTH = 32 + +/** Must match `MAX_VENUES` in oracle/contract/src/lib.rs. */ +export const MAX_VENUES = 16 + +/** i128 bounds; a price that does not fit is refused here rather than at the ledger. */ +const I128_MAX = (2n ** 127n) - 1n + +/** Raised for inputs that cannot be represented on chain at all. */ +export class Sep40EncodingError extends Error { + constructor(message: string) { + super(message) + this.name = 'Sep40EncodingError' + } +} + +/** + * SEP-40 §Interface `enum Asset { Stellar(Address), Other(Symbol) }`. + * + * A Stellar asset is named by the address of its Stellar Asset Contract, which + * is the only representation that is issuer-qualified: two tokens both called + * USDC are different assets, and a feed keyed on the code alone would blend + * them. Native XLM has no asset contract, so it takes the `Other` branch, which + * the spec leaves to the oracle's own naming. + */ +export type Sep40Asset = + | { kind: 'stellar'; contractId: string } + | { kind: 'other'; symbol: string } + +/** The `{ tag, values }` shape the SDK's Spec layer uses for a contracttype enum. */ +export interface SpecUnion { + tag: string + values: unknown[] +} + +function assertSymbol(name: string, what: string): string { + if (name.length === 0) { + throw new Sep40EncodingError(`${what} is empty; it cannot be a Soroban symbol`) + } + if (name.length > MAX_SYMBOL_LENGTH) { + throw new Sep40EncodingError( + `${what} "${name}" is ${name.length} characters, over the ${MAX_SYMBOL_LENGTH}-character Soroban symbol limit` + ) + } + if (!/^[A-Za-z0-9_]+$/.test(name)) { + throw new Sep40EncodingError( + `${what} "${name}" contains characters a Soroban symbol cannot hold (letters, digits and _ only)` + ) + } + return name +} + +/** + * Map one of Lens's watched assets to its SEP-40 identifier on `network`. + * + * The asset contract address is DERIVED (`Asset#contractId` hashes the asset's + * own LedgerKey under the network id) rather than looked up from a list, so + * there is no hand-copied address here to get wrong — and no issuer to elide: + * an anchored asset without an issuer is refused, because the derivation is + * what makes the identifier mean something. + */ +export function toSep40Asset(asset: AssetId, network: NetworkName): Sep40Asset { + if (asset.issuer === null) { + return { kind: 'other', symbol: assertSymbol(asset.code, `native asset ${asset.code}`) } + } + if (!StrKey.isValidEd25519PublicKey(asset.issuer)) { + throw new Sep40EncodingError( + `${asset.code}: issuer "${asset.issuer}" is not a valid G... account id, so its asset contract cannot be derived` + ) + } + const passphrase = getNetworkConfig(network).network.passphrase + let contractId: string + try { + contractId = new StellarAsset(asset.code, asset.issuer).contractId(passphrase) + } catch (err) { + throw new Sep40EncodingError( + `${asset.code}:${asset.issuer} has no Stellar Asset Contract id on ${network}: ${(err as Error).message}` + ) + } + return { kind: 'stellar', contractId } +} + +/** Canonical string identity of a `Sep40Asset`, used as a map key and for equality. */ +export function assetKey(asset: Sep40Asset): string { + return asset.kind === 'stellar' ? `stellar:${asset.contractId}` : `other:${asset.symbol}` +} + +export function assetToSpecArg(asset: Sep40Asset): SpecUnion { + return asset.kind === 'stellar' + ? { tag: 'Stellar', values: [asset.contractId] } + : { tag: 'Other', values: [asset.symbol] } +} + +/** Inverse of {@link assetToSpecArg}, for values read back from the feed. */ +export function assetFromSpecArg(value: SpecUnion | unknown): Sep40Asset { + if (!value || typeof value !== 'object' || !('tag' in value) || !('values' in value)) { + throw new Sep40EncodingError(`expected a SEP-40 Asset, got ${JSON.stringify(value)}`) + } + const { tag, values } = value as SpecUnion + const first = values?.[0] + if (tag === 'Stellar') { + if (typeof first !== 'string' || !StrKey.isValidContract(first)) { + throw new Sep40EncodingError(`Asset::Stellar carried "${String(first)}", which is not a C... contract id`) + } + return { kind: 'stellar', contractId: first } + } + if (tag === 'Other') { + if (typeof first !== 'string') { + throw new Sep40EncodingError(`Asset::Other carried ${String(first)}, which is not a symbol`) + } + return { kind: 'other', symbol: assertSymbol(first, 'Asset::Other') } + } + throw new Sep40EncodingError(`unknown SEP-40 Asset variant "${String(tag)}"`) +} + +/** + * Build the `Asset` SCVal by hand: a Soroban `contracttype` enum's tuple + * variant is `Vec [ Symbol(variant), ...fields ]` (see `unionToNative` in the + * SDK's Spec layer, which decodes exactly this shape). + * + * Exported so the encoding is asserted in tests rather than only observed when + * a write fails on chain. + */ +export function assetToScVal(asset: Sep40Asset): xdr.ScVal { + if (asset.kind === 'stellar') { + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol('Stellar'), + new Address(asset.contractId).toScVal(), + ]) + } + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol('Other'), + xdr.ScVal.scvSymbol(assertSymbol(asset.symbol, 'Asset::Other')), + ]) +} + +/** Read an `Asset` SCVal back (used by tests and by any raw-footprint decode). */ +export function assetFromScVal(value: xdr.ScVal): Sep40Asset { + const vec = value.vec() + if (!vec || vec.length !== 2) { + throw new Sep40EncodingError('expected a SEP-40 Asset encoded as Vec[Symbol, value]') + } + const tag = vec[0].sym().toString() + if (tag === 'Stellar') { + const contractId = Address.fromScVal(vec[1]).toString() + if (!StrKey.isValidContract(contractId)) { + throw new Sep40EncodingError(`Asset::Stellar decoded to "${contractId}", not a contract address`) + } + return { kind: 'stellar', contractId } + } + if (tag === 'Other') { + return { kind: 'other', symbol: assertSymbol(vec[1].sym().toString(), 'Asset::Other') } + } + throw new Sep40EncodingError(`unknown SEP-40 Asset variant "${tag}"`) +} + +/** + * Scale a human-readable price into the `i128` the feed stores. + * + * Done 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 `BigInt()` of it throws and `Math.round()` of it + * publishes a value a fraction of a ULP away from what the aggregate said. The + * drift is invisible either way, and it lands on chain where every consumer + * trusts it. `toFixed(decimals)` is exact to the decimal expansion of the value + * we hold and rounds once, in the same place every other price reader rounds. + */ +export function scalePrice(price: number, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { + throw new Sep40EncodingError( + `decimals must be an integer between 0 and 36 (the feed was configured with ${decimals})` + ) + } + if (!Number.isFinite(price)) { + throw new Sep40EncodingError(`price ${price} is not a finite number`) + } + if (price <= 0) { + throw new Sep40EncodingError( + `price ${price} is not positive; a zero or negative feed price is refused rather than published` + ) + } + const text = price.toFixed(decimals) + if (!/^\d+(\.\d+)?$/.test(text)) { + // toFixed switches to exponential notation past 1e21, where the digit + // string below would no longer be a number at all. + throw new Sep40EncodingError(`price ${price} cannot be written in fixed-point at ${decimals} decimals`) + } + const [whole, fraction = ''] = text.split('.') + const scaled = BigInt(whole + fraction) + if (scaled > I128_MAX) { + throw new Sep40EncodingError( + `price ${price} at ${decimals} decimals overflows i128; lower the feed's decimals` + ) + } + return scaled +} + +/** + * Inverse of {@link scalePrice}. Lossy by construction (a JS number holds ~15 + * significant digits), and used only to compare two prices for deviation, never + * to produce a published value. + */ +export function unscalePrice(scaled: bigint, decimals: number): number { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Sep40EncodingError(`decimals must be a non-negative integer (got ${decimals})`) + } + const text = scaled.toString().padStart(decimals + 1, '0') + const split = text.length - decimals + return Number(`${text.slice(0, split)}.${text.slice(split) || '0'}`) +} + +/** + * Trim a wall-clock reading to the feed's tick, per SEP-40: + * `floor(unix_now() / resolution) * resolution`. + * + * Trimming is what lets a consumer treat two reports in the same tick as the + * same point on the timeline, and the contract re-checks it, so a value that + * skipped this step is rejected rather than silently stored. + */ +export function trimTimestamp(nowMs: number, resolutionSeconds: number): number { + if (!Number.isInteger(resolutionSeconds) || resolutionSeconds <= 0) { + throw new Sep40EncodingError( + `resolution must be a positive whole number of seconds (the feed was configured with ${resolutionSeconds})` + ) + } + const unixSeconds = Math.floor(nowMs / 1000) + return Math.floor(unixSeconds / resolutionSeconds) * resolutionSeconds +} + +/** `Vec` of the venues behind an aggregate, as the contract wants it. */ +export function venuesToSpecArg(venues: string[]): string[] { + if (venues.length === 0) { + throw new Sep40EncodingError( + 'an aggregate with no named venue is not publishable; a price you cannot source is worse than no price' + ) + } + if (venues.length > MAX_VENUES) { + throw new Sep40EncodingError( + `${venues.length} venues exceed the feed's limit of ${MAX_VENUES}` + ) + } + // Sorted and de-duplicated: the venue list is part of the report's identity, + // and the same set of markets must not produce two different on-chain values + // because rows came back in a different order. `[...venues]` first, so the + // caller's array is not sorted in place under them. + return [...new Set([...venues].sort())].map(venue => assertSymbol(venue, `venue ${venue}`)) +} + +export function venuesToScVal(venues: string[]): xdr.ScVal { + return xdr.ScVal.scvVec(venuesToSpecArg(venues).map(name => xdr.ScVal.scvSymbol(name))) +} diff --git a/src/publish/trigger.ts b/src/publish/trigger.ts new file mode 100644 index 00000000..f7b14159 --- /dev/null +++ b/src/publish/trigger.ts @@ -0,0 +1,260 @@ +/** + * When Lens is allowed to write to its SEP-40 feed (#222). + * + * A tick is not an event. Lens polls every few seconds, so a naive publisher + * writes on every sample and turns a price feed into a transaction spam bot — + * while also publishing the noise between trades. The contract itself only + * knows how to store a report; the *policy* of when to call it lives here, as a + * pure function over (aggregate, what is already on chain, thresholds) so the + * whole rule set is testable without a chain, a database or a clock. + * + * Two rules dominate the ordering below: + * + * 1. "A stale aggregate is never published" is a guard, not a trigger. Every + * staleness check runs BEFORE any trigger is evaluated, so an old price can + * never reach chain because a timer elapsed. + * 2. "Every row carries the venue it came from" — an aggregate whose venues we + * cannot name is not publishable either. A blended number that quietly + * averages a live market and a dead one is worse than no feed at all. + * + * The deviation reference is the value ALREADY ON CHAIN (`lastprice`), not a + * local variable. Two publisher instances — a canary and the real one, or a + * restart racing itself — then agree on what "moved" means, and neither can + * double-write because both see the same last tick. + */ + +/** Codes are stable strings: they are what the audit row and the log line carry. */ +export type PublicationGuard = + /** We cannot name a single venue behind the number, so we cannot source it. */ + | 'no_venue' + /** Zero / negative / non-finite price. */ + | 'invalid_price' + /** No trade has ever been seen for this pair on this network. */ + | 'no_observation' + /** The aggregator itself flagged the series as stale. */ + | 'stale_aggregate' + /** Newer than the aggregator's own threshold, but older than the feed allows. */ + | 'aggregate_too_old' + /** The price moved, but less than `deviationPct`. */ + | 'below_deviation' + /** + * A trigger fired, but the previous report is too recent for another write. + * Writes are spaced by the feed's tick, so the effective floor on the spacing + * between reports is `max(minWriteIntervalMs, resolutionSeconds)`. + */ + | 'min_write_interval' + /** + * A trigger fired, but the tick has not advanced since the last report — the + * contract would refuse it (`NotNewer`), so we do not send a doomed tx. + */ + | 'tick_not_advanced' + +export type PublicationTrigger = 'first_report' | 'deviation' | 'max_interval' + +/** What Lens currently believes the pair is worth, as the aggregator returned it. */ +export interface AggregateSnapshot { + pairKey: string + /** + * Price in Lens's stored convention: units of assetB per assetA. The caller + * has already oriented it against the feed's base if needed — this module + * compares and publishes numbers, it does not decide which leg is which. + */ + price: number + /** Venue names behind this aggregate (`SDEX`, `aquarius_amm`, …). Never blended. */ + venues: string[] + /** The aggregator's own `stale` flag. */ + stale: boolean + /** Age of the newest trade in the series, or null if there has never been one. */ + lastTradeAgeSeconds: number | null +} + +/** What is already on chain for this asset, read from `lastprice`. */ +export interface PublishedSnapshot { + /** Human-readable price (already un-scaled by the feed's `decimals`). */ + price: number + /** Unix seconds, already trimmed to the feed's resolution. */ + timestamp: number +} + +export interface TriggerThresholds { + /** Percent move that justifies a write. */ + deviationPct: number + /** Publish even without movement once the feed is this old. */ + maxIntervalMs: number + /** Never write twice within this window. */ + minWriteIntervalMs: number + /** Refuse to publish an aggregate whose newest trade is older than this. */ + maxAgeSeconds: number + /** The feed's `resolution()` in seconds — its tick. */ + resolutionSeconds: number +} + +export type TriggerDecision = + | { + publish: true + trigger: PublicationTrigger + /** The `timestamp` to report: now, trimmed to the feed's tick. */ + feedTimestamp: number + /** Percent move against the on-chain price, for the log line. Null on first write. */ + deviationPct: number | null + /** Age of the on-chain value in ms at the moment of decision; null on first write. */ + publishedAgeMs: number | null + reason: string + } + | { + publish: false + guard: PublicationGuard + feedTimestamp: number + /** Set when a real trigger existed but a write veto stopped it. */ + suppressedBy?: PublicationTrigger + reason: string + } + +/** + * `floor(unix_now()/resolution)*resolution` — SEP-40's definition of a feed + * timestamp, kept here so the trigger and the writer can never disagree. + */ +export function feedTimestamp(nowMs: number, resolutionSeconds: number): number { + if (!Number.isInteger(resolutionSeconds) || resolutionSeconds <= 0) { + throw new Error(`resolution must be a positive whole number of seconds (got ${resolutionSeconds})`) + } + const unix = Math.floor(nowMs / 1000) + return Math.floor(unix / resolutionSeconds) * resolutionSeconds +} + +/** + * Percent distance from `from` to `to`, relative to `from`. An unreachable + * reference (zero or non-finite) reads as infinite deviation, which is the + * safe direction: a published value we cannot compare against gets corrected, + * not treated as "no movement" forever. + */ +export function deviationPercent(from: number, to: number): number { + if (!Number.isFinite(from) || !Number.isFinite(to) || from === 0) return Infinity + return (Math.abs(to - from) / Math.abs(from)) * 100 +} + +/** A decision not to write. `decidePublication` and `evaluateGuards` both return this. */ +export type BlockedDecision = Extract + +function block(guard: PublicationGuard, tick: number, reason: string): BlockedDecision { + return { publish: false, guard, feedTimestamp: tick, reason } +} + +/** + * The staleness and provenance guards, on their own. + * + * Exported separately because a caller that already knows it has a stale or + * unsourced number should find out without spending an RPC read: the publisher + * runs this first and skips the pair entirely, so a blocked tick touches no + * chain at all. `decidePublication` calls the same function — there is one list + * of guards, not two that can drift apart. + */ +export function evaluateGuards( + aggregate: AggregateSnapshot, + thresholds: TriggerThresholds, + tick: number +): BlockedDecision | null { + if (aggregate.venues.length === 0) { + return block('no_venue', tick, + `${aggregate.pairKey}: no venue is named behind ${aggregate.price}; a price you cannot source is worse than no price`) + } + if (!Number.isFinite(aggregate.price) || aggregate.price <= 0) { + return block('invalid_price', tick, + `${aggregate.pairKey}: price ${aggregate.price} is not a positive, finite number`) + } + if (aggregate.lastTradeAgeSeconds === null) { + return block('no_observation', tick, + `${aggregate.pairKey}: no trade has been recorded on this network, so there is nothing to publish`) + } + // The aggregator's own flag first: it is the same signal /api/price reports, + // so a consumer of the feed can never be shown a price the API calls stale. + if (aggregate.stale) { + return block('stale_aggregate', tick, + `${aggregate.pairKey}: aggregate is stale (last trade ${aggregate.lastTradeAgeSeconds}s ago); a stale aggregate is never published`) + } + if (aggregate.lastTradeAgeSeconds > thresholds.maxAgeSeconds) { + return block('aggregate_too_old', tick, + `${aggregate.pairKey}: last trade is ${aggregate.lastTradeAgeSeconds}s old, over the feed's ${thresholds.maxAgeSeconds}s limit`) + } + return null +} + +/** + * The whole publication policy, as one pure call. + * + * `published` is the current on-chain state for the asset under consideration, + * or null when the feed holds nothing for it yet. `nowMs` is injected so the + * interval and rate-limit branches are testable against a fixed clock. + */ +export function decidePublication( + aggregate: AggregateSnapshot, + published: PublishedSnapshot | null, + thresholds: TriggerThresholds, + nowMs: number +): TriggerDecision { + const tick = feedTimestamp(nowMs, thresholds.resolutionSeconds) + + const blocked = evaluateGuards(aggregate, thresholds, tick) + if (blocked) return blocked + + // Nothing on chain for this asset. The first report is unconditional once the + // guards pass: "the feed has never heard of this pair" is not a reason to + // keep it silent, and there is no rate limit against a value that does not + // exist. The tick itself is still the contract's monotonicity check. + if (published === null) { + return { + publish: true, + trigger: 'first_report', + feedTimestamp: tick, + deviationPct: null, + publishedAgeMs: null, + reason: `${aggregate.pairKey}: the feed holds no price for this asset, publishing ${aggregate.price} from ${aggregate.venues.join(', ')}`, + } + } + + const publishedAgeMs = Math.max(0, nowMs - published.timestamp * 1000) + const deviation = deviationPercent(published.price, aggregate.price) + const moved = deviation >= thresholds.deviationPct + const agedOut = publishedAgeMs >= thresholds.maxIntervalMs + + if (!moved && !agedOut) { + return block('below_deviation', tick, + `${aggregate.pairKey}: moved ${deviation.toFixed(3)}%, under the ${thresholds.deviationPct}% threshold, and the published price is ${Math.round(publishedAgeMs / 1000)}s old — inside the ${Math.round(thresholds.maxIntervalMs / 1000)}s max interval`) + } + + const trigger: PublicationTrigger = moved ? 'deviation' : 'max_interval' + const why = moved + ? `${aggregate.pairKey}: moved ${deviation.toFixed(3)}% from the published ${published.price} (threshold ${thresholds.deviationPct}%)` + : `${aggregate.pairKey}: the published price is ${Math.round(publishedAgeMs / 1000)}s old with no move above ${thresholds.deviationPct}% (max interval ${Math.round(thresholds.maxIntervalMs / 1000)}s)` + + // A trigger exists. Two write vetoes still apply, to BOTH branches — a + // deviation as big as a halving and an interval that merely elapsed are + // equally unable to justify two transactions inside the same tick. + if (tick <= published.timestamp) { + return { + publish: false, + guard: 'tick_not_advanced', + feedTimestamp: tick, + suppressedBy: trigger, + reason: `${why}, but the feed's current tick (${tick}) is not newer than the last report (${published.timestamp}); the contract would reject it as NotNewer`, + } + } + if (publishedAgeMs < thresholds.minWriteIntervalMs) { + return { + publish: false, + guard: 'min_write_interval', + feedTimestamp: tick, + suppressedBy: trigger, + reason: `${why}, but only ${Math.round(publishedAgeMs / 1000)}s have passed since the last report (minimum ${Math.round(thresholds.minWriteIntervalMs / 1000)}s between writes)`, + } + } + + return { + publish: true, + trigger, + feedTimestamp: tick, + deviationPct: Number.isFinite(deviation) ? deviation : null, + publishedAgeMs, + reason: `${why}; sources ${aggregate.venues.join(', ')}`, + } +}