Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/sep40-oracle-publisher.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading