From 22d0bf0073118e83bab54e81484218f8ba8135fd Mon Sep 17 00:00:00 2001 From: Fuad ALPHATIC Date: Sat, 30 May 2026 12:38:40 +0100 Subject: [PATCH] Add threshold alerts and oracle relay example Summary: - Extracted threshold alert matching and webhook delivery into a small shared alerts module. - Added a minimal Soroban oracle contract example plus a Node relay that polls Lens and pushes prices periodically. - Documented the relay example and added a convenience script. Closes #64 Closes #65 --- .env.example | 13 ++ README.md | 4 + examples/oracle-relay/README.md | 31 ++++ examples/oracle-relay/contract/Cargo.toml | 13 ++ examples/oracle-relay/contract/src/lib.rs | 22 +++ examples/oracle-relay/relay.ts | 176 ++++++++++++++++++++++ package.json | 1 + src/alerts/delivery.ts | 54 +++++++ src/alerts/index.ts | 2 + src/alerts/threshold.ts | 49 ++++++ src/webhookDispatcher.ts | 74 +-------- 11 files changed, 369 insertions(+), 70 deletions(-) create mode 100644 examples/oracle-relay/README.md create mode 100644 examples/oracle-relay/contract/Cargo.toml create mode 100644 examples/oracle-relay/contract/src/lib.rs create mode 100644 examples/oracle-relay/relay.ts create mode 100644 src/alerts/delivery.ts create mode 100644 src/alerts/index.ts create mode 100644 src/alerts/threshold.ts diff --git a/.env.example b/.env.example index 210584d9..74a9dce2 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,19 @@ ORACLE_PAYMENT_ADDRESS=GD... # URL of the x402 facilitator (default: https://facilitator.stellar.org) X402_FACILITATOR_URL=https://facilitator.stellar.org +# --- Oracle Relay Example --- +# Lens API base URL consumed by the relay example. +ORACLE_RELAY_API_URL=http://localhost:3002 +# Soroban contract ID for the relay example. +ORACLE_RELAY_CONTRACT_ID=CC... +# Secret key for the relay's source account. +ORACLE_RELAY_SOURCE_SECRET=S... +# Asset pair pushed to the oracle contract. +ORACLE_RELAY_ASSET_A=XLM +ORACLE_RELAY_ASSET_B=USDC +# How often the relay polls Lens and updates the contract. +ORACLE_RELAY_INTERVAL_MS=60000 + # --- Soroswap AMM Ingester --- # Mainnet Soroswap factory contract address # See: https://github.com/soroswap/core diff --git a/README.md b/README.md index a0e5bc78..051fe8f4 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,10 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). The API specification is available in [OpenAPI 3.0 format](openapi.yaml). +## Examples + +The [oracle relay example](examples/oracle-relay/README.md) shows a minimal Soroban contract plus a Node relay that reads Lens prices and pushes them on chain. + ## Docker Quickstart The fastest way to get Lens running locally is with Docker: diff --git a/examples/oracle-relay/README.md b/examples/oracle-relay/README.md new file mode 100644 index 00000000..6e516892 --- /dev/null +++ b/examples/oracle-relay/README.md @@ -0,0 +1,31 @@ +# Oracle Relay Example + +This example shows the smallest useful shape for issue #65: + +- a Soroban contract that stores the latest price as a string +- a Node relay that reads Lens REST prices on an interval and writes the latest value on chain + +## Files + +- `contract/src/lib.rs` contains the contract logic +- `contract/Cargo.toml` defines the Soroban contract package +- `relay.ts` runs the polling relay + +## Environment + +Set these variables before running the relay: + +- `ORACLE_RELAY_API_URL` +- `ORACLE_RELAY_CONTRACT_ID` +- `ORACLE_RELAY_SOURCE_SECRET` +- `ORACLE_RELAY_ASSET_A` +- `ORACLE_RELAY_ASSET_B` +- `ORACLE_RELAY_INTERVAL_MS` + +## Run + +```bash +npm run oracle:relay +``` + +The relay supports `--help` for usage and `--once` for a single update cycle. \ No newline at end of file diff --git a/examples/oracle-relay/contract/Cargo.toml b/examples/oracle-relay/contract/Cargo.toml new file mode 100644 index 00000000..88842fbe --- /dev/null +++ b/examples/oracle-relay/contract/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oracle_relay" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "20" + +[dev-dependencies] +soroban-sdk = { version = "20", features = ["testutils"] } \ No newline at end of file diff --git a/examples/oracle-relay/contract/src/lib.rs b/examples/oracle-relay/contract/src/lib.rs new file mode 100644 index 00000000..e429c261 --- /dev/null +++ b/examples/oracle-relay/contract/src/lib.rs @@ -0,0 +1,22 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Env, String}; + +#[contracttype] +enum DataKey { + Price, +} + +#[contract] +pub struct OracleRelayContract; + +#[contractimpl] +impl OracleRelayContract { + pub fn set_price(env: Env, price: String) { + env.storage().instance().set(&DataKey::Price, &price); + } + + pub fn get_price(env: Env) -> Option { + env.storage().instance().get(&DataKey::Price) + } +} \ No newline at end of file diff --git a/examples/oracle-relay/relay.ts b/examples/oracle-relay/relay.ts new file mode 100644 index 00000000..35c58d8a --- /dev/null +++ b/examples/oracle-relay/relay.ts @@ -0,0 +1,176 @@ +import 'dotenv/config' +import { pathToFileURL } from 'url' +import { + Account, + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + nativeToScVal, + rpc as SorobanRpc, + scValToNative, +} from '@stellar/stellar-sdk' + +const HELP_TEXT = `Usage: npm run oracle:relay [-- --once] + +Environment: + ORACLE_RELAY_API_URL=http://localhost:3002 + ORACLE_RELAY_CONTRACT_ID=... + ORACLE_RELAY_SOURCE_SECRET=... + ORACLE_RELAY_ASSET_A=XLM + ORACLE_RELAY_ASSET_B=USDC + ORACLE_RELAY_INTERVAL_MS=60000 +` + +export interface OracleRelayConfig { + apiUrl: string + contractId: string + sourceSecret: string + assetA: string + assetB: string + intervalMs: number + networkPassphrase: string + rpcUrl: string +} + +export interface OraclePriceSnapshot { + assetA: string + assetB: string + price: number + timestamp: string +} + +function readConfig(): OracleRelayConfig { + const apiUrl = process.env.ORACLE_RELAY_API_URL ?? 'http://localhost:3002' + const contractId = process.env.ORACLE_RELAY_CONTRACT_ID + const sourceSecret = process.env.ORACLE_RELAY_SOURCE_SECRET + const assetA = process.env.ORACLE_RELAY_ASSET_A ?? 'XLM' + const assetB = process.env.ORACLE_RELAY_ASSET_B ?? 'USDC' + const intervalMs = parseInt(process.env.ORACLE_RELAY_INTERVAL_MS ?? '60000', 10) + const rpcUrl = process.env.ORACLE_RELAY_RPC_URL ?? 'https://soroban-testnet.stellar.org' + const networkPassphrase = process.env.ORACLE_RELAY_NETWORK_PASSPHRASE ?? Networks.TESTNET + + if (!contractId) throw new Error('ORACLE_RELAY_CONTRACT_ID is required') + if (!sourceSecret) throw new Error('ORACLE_RELAY_SOURCE_SECRET is required') + + return { + apiUrl, + contractId, + sourceSecret, + assetA, + assetB, + intervalMs: Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000, + networkPassphrase, + rpcUrl, + } +} + +export async function fetchLensPrice(apiUrl: string, assetA: string, assetB: string): Promise { + const response = await fetch(`${apiUrl.replace(/\/$/, '')}/price/${assetA}/${assetB}`) + if (!response.ok) { + throw new Error(`Lens price request failed with HTTP ${response.status}`) + } + + const data = await response.json() as { assetA: string; assetB: string; price: number; lastUpdated?: string } + if (typeof data.price !== 'number') { + throw new Error('Lens price payload did not include a numeric price') + } + + return { + assetA: data.assetA, + assetB: data.assetB, + price: data.price, + timestamp: data.lastUpdated ?? new Date().toISOString(), + } +} + +export function formatOraclePrice(price: number): string { + return price.toFixed(7) +} + +async function loadSourceAccount(rpc: SorobanRpc.Server, source: Keypair): Promise { + const account = await rpc.getAccount(source.publicKey()) + return new Account(source.publicKey(), account.sequenceNumber()) +} + +export async function readOraclePrice( + rpc: SorobanRpc.Server, + config: OracleRelayConfig, +): Promise { + const source = Keypair.fromSecret(config.sourceSecret) + const sourceAccount = await loadSourceAccount(rpc, source) + const contract = new Contract(config.contractId) + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation(contract.call('get_price')) + .setTimeout(30) + .build() + + const simulation = await rpc.simulateTransaction(tx) + if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.result?.retval) { + return null + } + + const current = scValToNative(simulation.result.retval) + return typeof current === 'string' ? current : String(current) +} + +export async function pushOraclePrice( + rpc: SorobanRpc.Server, + config: OracleRelayConfig, + snapshot: OraclePriceSnapshot, +): Promise { + const source = Keypair.fromSecret(config.sourceSecret) + const sourceAccount = await loadSourceAccount(rpc, source) + const contract = new Contract(config.contractId) + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation(contract.call('set_price', nativeToScVal(formatOraclePrice(snapshot.price)))) + .setTimeout(30) + .build() + + const prepared = await rpc.prepareTransaction(tx) + prepared.sign(source) + await rpc.sendTransaction(prepared) +} + +export async function runOracleRelay(config = readConfig(), once = false): Promise { + const rpc = new SorobanRpc.Server(config.rpcUrl, { allowHttp: true }) + + do { + const snapshot = await fetchLensPrice(config.apiUrl, config.assetA, config.assetB) + const currentPrice = await readOraclePrice(rpc, config) + await pushOraclePrice(rpc, config, snapshot) + + console.log( + `[oracle-relay] ${snapshot.assetA}/${snapshot.assetB} price ${formatOraclePrice(snapshot.price)} ` + + `(on-chain: ${currentPrice ?? 'unavailable'})` + ) + + if (!once) { + await new Promise(resolve => setTimeout(resolve, config.intervalMs)) + } + } while (!once) +} + +async function main() { + if (process.argv.includes('--help')) { + console.log(HELP_TEXT.trim()) + return + } + + const once = process.argv.includes('--once') + await runOracleRelay(undefined, once) +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch(err => { + console.error('[oracle-relay] fatal error:', (err as Error).message) + process.exit(1) + }) +} \ No newline at end of file diff --git a/package.json b/package.json index 37ecf5ea..65176ffe 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "node node_modules/prisma/build/index.js generate && tsc", "start": "node dist/index.js", "test": "vitest run", + "oracle:relay": "tsx examples/oracle-relay/relay.ts", "db:push": "prisma db push", "db:generate": "prisma generate" }, diff --git a/src/alerts/delivery.ts b/src/alerts/delivery.ts new file mode 100644 index 00000000..c864f2d1 --- /dev/null +++ b/src/alerts/delivery.ts @@ -0,0 +1,54 @@ +import { createHmac } from 'crypto' + +function retryDelayMs(attempt: number): number { + return Math.pow(2, attempt - 1) * 1000 +} + +function signPayload(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex') +} + +export async function deliverJsonWithRetries( + url: string, + body: object, + secret: string, + attempt = 1, +): Promise { + const payload = JSON.stringify(body) + const signature = signPayload(payload, secret) + + let res: Response + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Lens-Signature': signature, + }, + body: payload, + signal: AbortSignal.timeout(10_000), + }) + } catch (err) { + if (attempt < 3) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs(attempt))) + return deliverJsonWithRetries(url, body, secret, attempt + 1) + } + + console.warn(`[webhook] delivery failed after ${attempt} attempts to ${url}:`, (err as Error).message) + return + } + + if (res.ok) return + + if (res.status >= 400 && res.status < 500) { + console.warn(`[webhook] client error ${res.status} from ${url} — not retrying`) + return + } + + if (attempt < 3) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs(attempt))) + return deliverJsonWithRetries(url, body, secret, attempt + 1) + } + + console.warn(`[webhook] delivery failed after ${attempt} attempts to ${url}: HTTP ${res.status}`) +} \ No newline at end of file diff --git a/src/alerts/index.ts b/src/alerts/index.ts new file mode 100644 index 00000000..f56e29d4 --- /dev/null +++ b/src/alerts/index.ts @@ -0,0 +1,2 @@ +export * from './delivery' +export * from './threshold' \ No newline at end of file diff --git a/src/alerts/threshold.ts b/src/alerts/threshold.ts new file mode 100644 index 00000000..fa159274 --- /dev/null +++ b/src/alerts/threshold.ts @@ -0,0 +1,49 @@ +export type ThresholdDirection = 'above' | 'below' + +export interface ThresholdAlertSubscription { + id: string + url: string + assetA: string + assetB: string + threshold: number + direction: ThresholdDirection + secret: string +} + +export interface ThresholdAlertPayload { + assetA: string + assetB: string + price: number + threshold: number + direction: ThresholdDirection + timestamp: string +} + +export function crossesThreshold( + subscription: Pick, + previousPrice: number, + currentPrice: number, +): boolean { + if (subscription.direction === 'above') { + return previousPrice < subscription.threshold && currentPrice >= subscription.threshold + } + + return previousPrice > subscription.threshold && currentPrice <= subscription.threshold +} + +export function buildThresholdAlertPayload( + subscription: Pick, + assetA: string, + assetB: string, + price: number, + timestamp = new Date().toISOString(), +): ThresholdAlertPayload { + return { + assetA, + assetB, + price, + threshold: subscription.threshold, + direction: subscription.direction, + timestamp, + } +} \ No newline at end of file diff --git a/src/webhookDispatcher.ts b/src/webhookDispatcher.ts index 8942f13b..03d6d020 100644 --- a/src/webhookDispatcher.ts +++ b/src/webhookDispatcher.ts @@ -1,5 +1,5 @@ -import { createHmac } from 'crypto' import { prisma } from './db' +import { buildThresholdAlertPayload, crossesThreshold, deliverJsonWithRetries } from './alerts' export interface PriceUpdate { assetA: string @@ -8,59 +8,6 @@ export interface PriceUpdate { currentPrice: number } -function sign(payload: string, secret: string): string { - return createHmac('sha256', secret).update(payload).digest('hex') -} - -async function deliver( - url: string, - body: object, - secret: string, - attempt = 1 -): Promise { - const payload = JSON.stringify(body) - const signature = sign(payload, secret) - - let res: Response - try { - res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Lens-Signature': signature, - }, - body: payload, - signal: AbortSignal.timeout(10_000), - }) - } catch (err) { - // Network error — retry if attempts remain - if (attempt < 3) { - const delay = Math.pow(2, attempt - 1) * 1000 // 1s, 2s, 4s - await new Promise(r => setTimeout(r, delay)) - return deliver(url, body, secret, attempt + 1) - } - console.warn(`[webhook] delivery failed after ${attempt} attempts to ${url}:`, (err as Error).message) - return - } - - if (res.ok) return - - // 4xx = client error, do not retry - if (res.status >= 400 && res.status < 500) { - console.warn(`[webhook] client error ${res.status} from ${url} — not retrying`) - return - } - - // 5xx — retry if attempts remain - if (attempt < 3) { - const delay = Math.pow(2, attempt - 1) * 1000 - await new Promise(r => setTimeout(r, delay)) - return deliver(url, body, secret, attempt + 1) - } - - console.warn(`[webhook] delivery failed after ${attempt} attempts to ${url}: HTTP ${res.status}`) -} - export async function dispatchPriceUpdate(update: PriceUpdate): Promise { const { assetA, assetB, previousPrice, currentPrice } = update @@ -73,13 +20,7 @@ export async function dispatchPriceUpdate(update: PriceUpdate): Promise { if (webhooks.length === 0) return - const triggered = webhooks.filter(wh => { - if (wh.direction === 'above') { - return previousPrice < wh.threshold && currentPrice >= wh.threshold - } - // below - return previousPrice > wh.threshold && currentPrice <= wh.threshold - }) + const triggered = webhooks.filter(wh => crossesThreshold(wh, previousPrice, currentPrice)) if (triggered.length === 0) return @@ -87,16 +28,9 @@ export async function dispatchPriceUpdate(update: PriceUpdate): Promise { await Promise.allSettled( triggered.map(wh => - deliver( + deliverJsonWithRetries( wh.url, - { - assetA, - assetB, - price: currentPrice, - threshold: wh.threshold, - direction: wh.direction, - timestamp, - }, + buildThresholdAlertPayload(wh, assetA, assetB, currentPrice, timestamp), wh.secret ) )