Implements issue #267 — integrate a reliable Stellar price oracle to fetch real-time valuations of user collateral: XLM and BTC prices every 5 seconds, with a fallback mechanism when the oracle is down.
Before this change, every collateral valuation ran on a hardcoded constant:
LIQUIDATION_XLM_PRICE_USD=0.12 # only changed when a human edited itThe liquidation keeper decided whether to seize someone's collateral by comparing their debt against that number. If XLM moved and nobody updated the variable, the keeper would either liquidate healthy positions or leave genuinely under-collateralized ones alone. Both are bad; the first is worse.
CoinGecko Binance Stellar DEX (Horizon)
│ │ │
└─────────────┼────────────────┘
│ collectQuotes() — Promise.allSettled,
│ one dead source never blocks the others
▼
┌───────────────────┐
│ aggregatePrice() │ reject outliers → median
└─────────┬─────────┘
│
┌──────────────┴───────────────┐
│ fallback chain │
│ 1. live median │
│ 2. last known good (cache) │
│ 3. on-chain TWAP │
│ 4. refuse to publish │
└──────────────┬───────────────┘
▼
set_asset_oracle_prices() on LendingContract
│
▼
get_asset_price() → median → TWAP → face value
The contract already had the on-chain half — set_asset_oracle_prices,
set_asset_twap_price, and a median → TWAP → face value resolution in
get_asset_price. Nothing was ever pushing real prices into it. This work
supplies the missing off-chain feeder.
A scheduled cron route alone cannot meet the acceptance criterion (Vercel Hobby
allows one run per day; GitHub Actions bottoms out at five minutes). The 5-second cadence comes from a long-lived
process, mirroring how liquidation-keeper handles its own sub-minute mode:
npm run price:oracle # 5s loop — this is the one that meets the criterion
npm run price:oracle:once # single cycle, for coarse schedulers
npm run price:oracle:dry # aggregate and log, publish nothing
npm run price:oracle -- --interval=15Run it under Docker, systemd or PM2 on any always-on host.
/api/cron/price-oracle is also triggered every 5 minutes by
.github/workflows/keepers.yml (and once a day by vercel.json) as a
safety net — if the long-lived keeper dies, prices still refresh every few
minutes rather than going completely stale. It is not a substitute for the
keeper. Note that each cron invocation starts with empty state, so its
in-memory cache fallback is unavailable and it falls straight through to TWAP.
The loop subtracts the time already spent on work before sleeping, so a slow cycle does not silently stretch the cadence.
This is the second acceptance criterion, and the order matters:
| Step | Source | When it applies |
|---|---|---|
| 1 | Live median | Any source responded with a fresh, usable quote |
| 2 | Cache | Every source failed, but the last good price is within ORACLE_MAX_STALENESS_MS |
| 3 | On-chain TWAP | Sources failed and the cache has aged out |
| 4 | Refuse to publish | Nothing usable anywhere |
Step 4 is deliberate. During a total outage a stale price is more dangerous than no price: the market may have moved through a borrower's liquidation threshold while the feed was frozen, so publishing the last number we happen to remember can trigger a liquidation at a valuation that no longer exists. The keeper logs an error, alerts, and leaves the previous on-chain value in place.
Two related safeguards:
- A cached price never renews its own freshness. Only a genuinely
livereading refreshes the last-known-good timestamp. Otherwise a stale price would keep resetting its own age and never fall through to TWAP. - Unusable quotes are dropped before aggregation. Zero, negative,
NaNandInfinityhave all been emitted by real price APIs during incidents, and any one of them would produce a nonsensical valuation.
Three independent adapters, each of which returns [] rather than throwing:
| Source | Symbols | Notes |
|---|---|---|
| CoinGecko | XLM, BTC | Optional COINGECKO_API_KEY for higher rate limits |
| Binance | XLM, BTC | USDT pairs, treated as 1 USD |
| Stellar DEX | XLM only | Horizon orderbook mid-price vs USDC |
The Stellar DEX source is the only one native to the chain TrustLend runs on, so the feed keeps working even if every centralised API is unreachable. BTC is deliberately not quoted from the Stellar DEX — its orderbook there is thin enough that a mid-price would be misleading.
Outlier rejection: with three or more sources, any quote deviating more than
DEFAULT_OUTLIER_TOLERANCE_BPS (10%) from the group median is discarded, which
catches a decimal-shifted or zeroed feed before it drags the aggregate. With
only two sources there is no majority to appeal to, so both are kept and the
median is their average.
Every request has a hard timeout well under the poll interval, so one hung source cannot stall a 5-second cycle.
Writing every 5 seconds regardless of movement would burn fees for nothing, so
shouldPublish() writes only when:
- there is no on-chain price yet, or
- the price moved at least 25 bps (0.25%), or
- the last publish was more than 60 seconds ago (heartbeat).
An unavailable price is never published, whatever the timings say.
runLiquidationKeeper now asks the oracle for XLM before valuing anything:
live oracle price → used for every valuation in the run
↓ (nothing usable)
LIQUIDATION_XLM_PRICE_USD → the old constant, now only a fallback
The keeper never aborts a run over a price lookup — it logs and falls back.
Live lookups are disabled automatically under test (VITEST / NODE_ENV=test)
and via LIQUIDATION_USE_LIVE_PRICES=false, so unit tests never touch a real
price API.
The keeper ships with a dry-run publisher: it aggregates and logs, but does not sign transactions. Signing needs an authorised key, so publishing is deliberately opt-in rather than something that silently starts moving state.
To go live, implement PricePublisher against the contract:
// publish → set_asset_oracle_prices(caller, asset, [price])
// readTwap → read DataKey::OracleTwapPrice for the assetset_asset_oracle_prices is multisig-gated (assert_multisig_admin), so
the signer must be a registered multisig admin, and the asset must already be
whitelisted. Configure the assets first:
stellar contract invoke --id "$LENDING_ID" -- set_asset_collateral_config \
--caller "$ADMIN" --asset "$XLM_ASSET" \
--config '{"collateral_factor_bps":7500,"has_price_oracle":true,"volatility_bps":2000}'has_price_oracle: true is what makes get_asset_price consult the oracle at
all; with it false the asset is valued at face value regardless of what is
published.
Seed a TWAP as well, so step 3 of the fallback chain is real rather than theoretical:
stellar contract invoke --id "$LENDING_ID" -- set_asset_twap_price \
--caller "$ADMIN" --asset "$XLM_ASSET" --price 1200000See the Collateral Price Oracle block in .env.example. The ones that matter
most:
| Variable | Default | Meaning |
|---|---|---|
ORACLE_POLL_INTERVAL_SECS |
5 |
Poll cadence |
ORACLE_MAX_STALENESS_MS |
120000 |
Age past which a price is unusable |
ORACLE_XLM_ASSET_ADDRESS |
— | Without it, XLM is aggregated but never published |
ORACLE_BTC_ASSET_ADDRESS |
— | Same for BTC |
ORACLE_DISABLE_* |
false |
Drop an individual source |
__tests__/lib/oracle-prices.test.ts— aggregation, outlier rejection, the full fallback chain, fixed-point conversion, publish policy.__tests__/lib/oracle-sources.test.ts— each adapter against an injected fetch, including timeouts, non-OK responses, malformed JSON, and one source throwing while others succeed.__tests__/scripts/price-oracle-keeper.test.ts— config parsing, a full cycle for both symbols, every fallback branch, per-symbol failure isolation, and cache hygiene.
npm test -- oracle- Publishing is dry-run until a
PricePublisheris implemented. The aggregation, fallback and policy layers are complete and tested; the on-chain write is a documented seam, because it needs a multisig-admin signer that cannot be safely defaulted. - BTC has no Stellar-native source, so during a total CEX/API outage BTC falls to cache then TWAP faster than XLM does.
- USDT is treated as 1 USD. At this scale the median across sources absorbs a small depeg, but a severe one would bias the Binance leg.