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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
31 changes: 31 additions & 0 deletions examples/oracle-relay/README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions examples/oracle-relay/contract/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
22 changes: 22 additions & 0 deletions examples/oracle-relay/contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
env.storage().instance().get(&DataKey::Price)
}
}
176 changes: 176 additions & 0 deletions examples/oracle-relay/relay.ts
Original file line number Diff line number Diff line change
@@ -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<OraclePriceSnapshot> {
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<Account> {
const account = await rpc.getAccount(source.publicKey())
return new Account(source.publicKey(), account.sequenceNumber())
}

export async function readOraclePrice(
rpc: SorobanRpc.Server,
config: OracleRelayConfig,
): Promise<string | null> {
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<void> {
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<void> {
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)
})
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
54 changes: 54 additions & 0 deletions src/alerts/delivery.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`)
}
2 changes: 2 additions & 0 deletions src/alerts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './delivery'
export * from './threshold'
49 changes: 49 additions & 0 deletions src/alerts/threshold.ts
Original file line number Diff line number Diff line change
@@ -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<ThresholdAlertSubscription, 'threshold' | 'direction'>,
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<ThresholdAlertSubscription, 'threshold' | 'direction'>,
assetA: string,
assetB: string,
price: number,
timestamp = new Date().toISOString(),
): ThresholdAlertPayload {
return {
assetA,
assetB,
price,
threshold: subscription.threshold,
direction: subscription.direction,
timestamp,
}
}
Loading
Loading