From fec36a5b53ab2c77abaca3f48881962c9cfb3654 Mon Sep 17 00:00:00 2001 From: Victor Adeleke <133906978+titilope12@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:42:08 +0000 Subject: [PATCH] docs: add Reflector oracle integration guide for Stellar fiat conversion --- docs.json | 6 +- guides/integrations/reflector.mdx | 563 ++++++++++++++++++++++++++++++ guides/stellar-fees.mdx | 9 + 3 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 guides/integrations/reflector.mdx diff --git a/docs.json b/docs.json index 9761769..d358711 100644 --- a/docs.json +++ b/docs.json @@ -19,7 +19,7 @@ ["meta-address", "metaaddress", "meta address"], ["Soroban", "Stellar smart contract"], ["friendbot", "testnet faucet"], - ["XLM", "lumen"], + ["XLM", "lumen", "reflector"], ["Freighter", "Stellar wallet"] ], "boosts": [ @@ -139,6 +139,10 @@ "guides/stellar-custom-assets", "guides/wraith-names-stellar" ] + }, + { + "group": "Integrations", + "pages": ["guides/integrations/reflector"] } ] }, diff --git a/guides/integrations/reflector.mdx b/guides/integrations/reflector.mdx new file mode 100644 index 0000000..5397c8b --- /dev/null +++ b/guides/integrations/reflector.mdx @@ -0,0 +1,563 @@ +--- +title: "Reflector Oracle Integration" +description: "Plug Reflector, the canonical Stellar oracle, into Wraith flows for inline fiat conversion. Read price feeds, render fiat equivalents, and handle oracle downtime gracefully." +keywords: "Stellar, soroban, reflector, oracle, fiat, price feed, XLMUSD, fiat conversion" +--- + +Reflector is the canonical oracle provider for the Stellar network's Soroban smart contract platform. It delivers on-chain price feeds backed by decentralized consensus — multiple trusted node operators independently source data from CEXs, DEXs, and on-chain Stellar markets, then a quorum must agree before a price update is accepted. + +This guide shows how to query Reflector price feeds from TypeScript, cache them efficiently, render fiat equivalents next to crypto amounts in your Wraith-powered UI, and handle oracle downtime gracefully. + +--- + +## Contract Addresses + +Reflector maintains separate deployments for mainnet and testnet. For fiat exchange rates, use the following contract IDs: + +| Network | Contract ID | +|---|---| +| Testnet | `CCSSOHTBL3LEWUCBBEB5NJFC2OKFRC74OWEIJIZLRJBGAAU4VMU5NV4W` | +| Mainnet | `CBKGPWGKSKZF52CFHMTRR23TBWTPMRDIYZ4O2P5VS65BMHYH4DXMCJZC` | + + + Reflector offers two data models: **ReflectorPulse** (free, 5-minute update interval) and **ReflectorBeam** (paid, more frequent updates with custom triggers). The fiat rates contract uses the Pulse model — prices refresh every ~5 minutes. + + +The fiat rates contract exposes two read-only functions: + +| Function | Returns | Description | +|---|---|---| +| `lastprice(ticker)` | `i128` | Most recent price for the given ticker (e.g. `"XLMUSD"`) | +| `decimals()` | `u32` | Number of decimal places used to represent prices | + +To get the actual floating-point price: `price / 10^decimals`. + +--- + +## Reading Price Feeds + +Query Reflector from TypeScript using `@stellar/stellar-sdk`'s Soroban RPC client. Read-only contract calls are free — they run as simulations and cost zero stroops. + +### Simulation Helper + +First, a helper that builds and simulates a Soroban contract invocation. This follows the same pattern used throughout the Wraith codebase for contract interactions: + +```typescript +import { + SorobanRpc, + TransactionBuilder, + Networks, + Operation, + scValToNative, + nativeToScVal, + xdr, +} from "@stellar/stellar-sdk"; + +async function simulateContractCall( + server: SorobanRpc.Server, + contractId: string, + method: string, + args: xdr.ScVal[], + sourcePublicKey: string, + networkPassphrase: string = Networks.TESTNET +): Promise { + // Fetch source account for the sequence number + const sourceAccount = await server.getAccount(sourcePublicKey); + + // Build a read-only transaction + const tx = new TransactionBuilder(sourceAccount, { + fee: "100", + networkPassphrase, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: contractId, + function: method, + args, + }) + ) + .setTimeout(30) + .build(); + + // Simulate — runs on the Soroban RPC but is never submitted to chain + const result = await server.simulateTransaction(tx); + + // Return the contract's return value as a raw ScVal + return result.result.retval; +} +``` + + + The `sourcePublicKey` can be any funded Stellar account — its balance is not deducted for simulations. + For testnet, you can use a freshly Friendbot-funded account or any well-known public key. + + +### Connecting to the Network + +```typescript +// Connect to testnet Soroban RPC +const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org"); + +// Fiat rates contract +const REFLECTOR_FIAT_CONTRACT = + "CCSSOHTBL3LEWUCBBEB5NJFC2OKFRC74OWEIJIZLRJBGAAU4VMU5NV4W"; +``` + +### Fetching a Single Price + +Call `lastprice` by building a simulated transaction. The ticker is a 32-byte `BytesN<32>` ScVal — pad the ticker to 32 bytes and pass it as a `Buffer`: + +```typescript +async function fetchReflectorPrice( + server: SorobanRpc.Server, + ticker: string, + sourcePublicKey: string +): Promise<{ price: number; decimals: number }> { + // Pad ticker to 32 bytes (Reflector expects fixed-width identifiers) + const tickerBytes = Buffer.from(ticker.padEnd(32, "\0"), "utf-8"); + + // Step 1 — fetch decimals (cached separately, only needed once) + const decimalsScVal = await simulateContractCall( + server, REFLECTOR_FIAT_CONTRACT, "decimals", [], sourcePublicKey + ); + const decimals = scValToNative(decimalsScVal) as number; + + // Step 2 — fetch lastprice(ticker) + const priceScVal = await simulateContractCall( + server, REFLECTOR_FIAT_CONTRACT, "lastprice", + [nativeToScVal(tickerBytes)], + sourcePublicKey + ); + const rawPrice = scValToNative(priceScVal) as bigint; + + return { + price: Number(rawPrice) / Math.pow(10, decimals), + decimals, + }; +} +``` + + + Reflector tickers use 32-byte fixed-width identifiers padded with null bytes. Common tickers include `XLMUSD`, `XLMEUR`, `XLMGBP`, and `XLMJPY`. The ticker is case-sensitive — `xlmUSD` will not match. + + +### Fetching Multiple Prices + +Batch multiple ticker queries for efficiency. Fetch decimals once, then run all price simulations in parallel: + +```typescript +async function fetchMultiplePrices( + server: SorobanRpc.Server, + tickers: string[], + sourcePublicKey: string +): Promise> { + // Fetch decimals once + const decimalsScVal = await simulateContractCall( + server, REFLECTOR_FIAT_CONTRACT, "decimals", [], sourcePublicKey + ); + const decimals = scValToNative(decimalsScVal) as number; + + // Fetch all prices in parallel + const priceResults = await Promise.all( + tickers.map(async (ticker) => { + const tickerBytes = Buffer.from(ticker.padEnd(32, "\0"), "utf-8"); + const priceScVal = await simulateContractCall( + server, REFLECTOR_FIAT_CONTRACT, "lastprice", + [nativeToScVal(tickerBytes)], + sourcePublicKey + ); + return { + ticker, + rawPrice: scValToNative(priceScVal) as bigint, + }; + }) + ); + + // Convert to readable prices + const prices: Record = {}; + for (const { ticker, rawPrice } of priceResults) { + prices[ticker] = Number(rawPrice) / Math.pow(10, decimals); + } + + return prices; +} +``` + +--- + +## Rendering "≈ $x" Next to Amounts + +Once you have the price, rendering a fiat equivalent is straightforward. Convert the crypto amount to fiat and display it alongside the native amount: + +```typescript +function renderFiatEquivalent( + amount: number, + asset: string, + priceXLMUSD: number +): string { + // For XLM: multiply directly + // For tokens with a known XLM pair rate, you'd chain through + const fiatValue = amount * priceXLMUSD; + return `≈ $${fiatValue.toFixed(2)}`; +} + +// Usage +const xlmAmount = 100; +const xlmPrice = 0.12; // fetched from Reflector + +console.log(`${xlmAmount} XLM ${renderFiatEquivalent(xlmAmount, "XLM", xlmPrice)}`); +// "100 XLM ≈ $12.00" +``` + +### Integrating with Wraith Payment Flows + +Use the fiat equivalent in payment confirmations, balance displays, and transaction summaries: + +```typescript +async function displayPaymentWithFiat( + agent: any, + recipient: string, + amount: number, + asset: string, + server: SorobanRpc.Server +) { + // Fetch current XLM/USD price from Reflector + const { price: xlmPrice } = await fetchReflectorPrice( + server, "XLMUSD", process.env.SOURCE_PUBLIC_KEY! + ); + + // Send the payment via Wraith agent + const response = await agent.chat( + `send ${amount} ${asset} to ${recipient} on stellar` + ); + + // Render confirmation with fiat value + const fiatStr = renderFiatEquivalent(amount, asset, xlmPrice); + console.log( + `Payment sent — ${amount} ${asset} ${fiatStr} to ${recipient}` + ); + console.log(response.response); +} +``` + +### Rounding Conventions + +| Amount Range | Fiat Precision | Example | +|---|---|---| +| < $0.01 | 4 decimal places | `≈ $0.0082` | +| $0.01 – $1 | 3 decimal places | `≈ $0.123` | +| $1 – $1,000 | 2 decimal places | `≈ $12.34` | +| > $1,000 | 0 decimal places | `≈ $1,234` | + +```typescript +function formatFiatSmart(value: number): string { + if (value === 0) return "$0.00"; + const abs = Math.abs(value); + const sign = value < 0 ? "-" : ""; + + let formatted: string; + if (abs < 0.01) formatted = abs.toFixed(4); + else if (abs < 1) formatted = abs.toFixed(3); + else if (abs < 1000) formatted = abs.toFixed(2); + else formatted = Math.round(abs).toLocaleString(); + + return `${sign}$${formatted}`; +} +``` + +--- + +## Cache Strategy (60s TTL) + +Reflector Pulse prices update every ~5 minutes. Caching with a 60-second TTL avoids redundant RPC calls while keeping fiat values fresh enough for UX. Use an in-memory cache keyed by ticker: + +```typescript +interface CacheEntry { + data: T; + expiresAt: number; +} + +class PriceCache { + private store = new Map>(); + private ttlMs: number; + + constructor(ttlSeconds = 60) { + this.ttlMs = ttlSeconds * 1000; + } + + get(ticker: string): number | null { + const entry = this.store.get(ticker); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.store.delete(ticker); + return null; + } + return entry.data; + } + + /** Get the raw cache entry (including expiry) for staleness checks */ + getEntry(ticker: string): CacheEntry | null { + return this.store.get(ticker) ?? null; + } + + set(ticker: string, price: number): void { + this.store.set(ticker, { + data: price, + expiresAt: Date.now() + this.ttlMs, + }); + } + + /** Purge all expired entries — call periodically */ + purge(): number { + let removed = 0; + for (const [key, entry] of this.store) { + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + removed++; + } + } + return removed; + } +} +``` + +### Cached Price Fetcher + +Wrap the raw fetcher with the cache so every caller gets the cached value automatically: + +```typescript +const priceCache = new PriceCache(60); // 60-second TTL + +async function getCachedPrice( + server: SorobanRpc.Server, + ticker: string, + sourcePublicKey: string +): Promise { + // Check cache first + const cached = priceCache.get(ticker); + if (cached !== null) return cached; + + // Cache miss — fetch from Reflector + const { price } = await fetchReflectorPrice(server, ticker, sourcePublicKey); + + // Store in cache + priceCache.set(ticker, price); + return price; +} +``` + + + For serverless environments (Lambda, Vercel Functions), use an external cache like Redis or your platform's edge cache instead of an in-memory `Map`. The same 60-second TTL applies — set it as the key expiration. + + +### Periodic Purge + +Run a purge every 5 minutes to clean up expired entries and prevent memory leaks: + +```typescript +// Purge expired cache entries every 5 minutes +setInterval(() => { + const purged = priceCache.purge(); + if (purged > 0) { + console.log(`Price cache: purged ${purged} expired entries`); + } +}, 5 * 60 * 1000); +``` + +--- + +## Fallback Behavior on Oracle Downtime + +Reflector's decentralized consensus provides strong availability — multiple node operators must agree on price updates, so a single node failure doesn't affect the feed. However, the RPC endpoint itself can experience congestion or downtime. Implement a multi-layered fallback: + +### Layer 1 — Stale Cache + +If the oracle is unreachable but a recent cached price exists, serve the stale value with a warning. A price from 5 minutes ago is better than no price at all for UI display purposes: + +```typescript +const MAX_STALE_AGE_MS = 10 * 60 * 1000; // 10 minutes max staleness + +interface PriceResult { + price: number; + source: "fresh" | "cache" | "stale" | "fallback"; +} + +async function getPriceWithFallback( + server: SorobanRpc.Server, + ticker: string, + sourcePublicKey: string, + fallbackPrice?: number +): Promise { + try { + const { price } = await fetchReflectorPrice(server, ticker, sourcePublicKey); + priceCache.set(ticker, price); + return { price, source: "fresh" }; + } catch (err) { + // Layer 2 — stale cache + const staleEntry = priceCache.getEntry(ticker); + if (staleEntry) { + const ageMs = Date.now() - (staleEntry.expiresAt - 60 * 1000); + if (ageMs < MAX_STALE_AGE_MS) { + console.warn( + `Reflector unavailable — serving stale price for ${ticker} (${(ageMs / 1000).toFixed(0)}s old)` + ); + return { price: staleEntry.data, source: "stale" }; + } + } + + // Layer 3 — hardcoded fallback + if (fallbackPrice !== undefined) { + console.warn( + `Reflector unavailable and no usable cache — using fallback for ${ticker}` + ); + return { price: fallbackPrice, source: "fallback" }; + } + + throw new Error( + `Failed to fetch price for ${ticker} and no fallback configured` + ); + } +} +``` + +### Layer 2 — Stale Cache (Extended) + +If the oracle is down and the 60s TTL has expired, consider serving the stale cache entry for up to 10 minutes before falling back to a hardcoded value. This handles transient RPC outages gracefully. + +### Layer 3 — Hardcoded Fallback + +Maintain a configurable fallback price map updated periodically (e.g., on deploy or via a separate cron job). This is a last resort — the price will be stale but prevents the UI from breaking: + +```typescript +// Fallback prices updated on deploy or via background cron +const FALLBACK_PRICES: Record = { + XLMUSD: 0.12, + XLMEUR: 0.11, + XLMGBP: 0.095, +}; + +async function getPriceWithLayeredFallback( + server: SorobanRpc.Server, + ticker: string, + sourcePublicKey: string +): Promise { + return getPriceWithFallback(server, ticker, sourcePublicKey, FALLBACK_PRICES[ticker]); +} +``` + +### Rendering with Source Indicator + +Show the price source in the UI so users understand the freshness: + +```typescript +function renderPriceWithSource(result: PriceResult): string { + const formatted = formatFiatSmart(result.price); + + switch (result.source) { + case "fresh": + return formatted; + case "cache": + return formatted; + case "stale": + return `${formatted} ⚠️`; + case "fallback": + return `${formatted} ⚠️`; + default: + return formatted; + } +} +``` + +--- + +## Full End-to-End Example + +A runnable example that fetches the XLM/USD price from Reflector on testnet, caches it, displays a Wraith balance with fiat equivalents, and handles failures gracefully. + + + This example depends on helpers defined in earlier sections: `PriceCache`, `getPriceWithLayeredFallback`, `renderPriceWithSource`, and `formatFiatSmart`. + Copy those into your project before running. + + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import { SorobanRpc } from "@stellar/stellar-sdk"; + +// ─── Reflector Oracle Setup ──────────────────────────────────────── + +const REFLECTOR_FIAT_CONTRACT = + "CCSSOHTBL3LEWUCBBEB5NJFC2OKFRC74OWEIJIZLRJBGAAU4VMU5NV4W"; +const TESTNET_RPC = "https://soroban-testnet.stellar.org"; + +const priceCache = new PriceCache(60); +const FALLBACK_PRICES: Record = { + XLMUSD: 0.12, +}; + +// ─── Main Application ────────────────────────────────────────────── + +async function main() { + // Connect to Soroban RPC + const server = new SorobanRpc.Server(TESTNET_RPC); + + // Initialize Wraith agent + if (!process.env.WRAITH_API_KEY) throw new Error("Missing WRAITH_API_KEY"); + if (!process.env.AGENT_ID) throw new Error("Missing AGENT_ID"); + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY }); + const agent = wraith.agent(process.env.AGENT_ID); + + // Fetch price with layered fallback + const priceResult = await getPriceWithLayeredFallback( + server, "XLMUSD", process.env.SOURCE_PUBLIC_KEY! + ); + console.log( + `XLM/USD: ${renderPriceWithSource(priceResult)} (source: ${priceResult.source})` + ); + + // Get agent balance + const balance = await agent.getBalance(); + const xlmBalance = parseFloat(balance.native ?? "0"); + + // Render balance with fiat equivalent + const fiatValue = xlmBalance * priceResult.price; + console.log( + `Balance: ${xlmBalance} XLM (≈ ${formatFiatSmart(fiatValue)})` + ); + + // Scan for incoming payments + const scan = await agent.chat("scan for payments on stellar"); + console.log("Scan result:", scan.response); + + // Send payment with fiat-aware confirmation + const amount = 10; + const recipient = "bob.wraith"; + const fiatStr = formatFiatSmart(amount * priceResult.price); + + console.log(`Sending ${amount} XLM (${fiatStr}) to ${recipient}...`); + const payment = await agent.chat( + `send ${amount} XLM to ${recipient} on stellar` + ); + console.log(payment.response); + + // Periodic cache cleanup + setInterval(() => { + const purged = priceCache.purge(); + if (purged > 0) { + console.log(`Price cache: purged ${purged} expired entries`); + } + }, 5 * 60 * 1000); +} + +main().catch(console.error); +``` + +--- + +## See Also + +- [Stellar Fee Estimation & Budgeting](/guides/stellar-fees) — understand the fee model for Stellar operations alongside fiat pricing +- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production recipes that can use fiat equivalents in payment flows +- [Stellar Payment Links](/guides/stellar-payment-links) — generate payment links with fiat amounts +- [Stellar Custom Assets](/guides/stellar-custom-assets) — handle USDC and other non-native assets with fiat pricing +- [Reflector Network Docs](https://reflector.network/docs) — official Reflector documentation +- [Stellar Oracle Providers](https://developers.stellar.org/docs/data/oracles/oracle-providers) — Stellar's official oracle overview diff --git a/guides/stellar-fees.mdx b/guides/stellar-fees.mdx index a344e7a..21ca13e 100644 --- a/guides/stellar-fees.mdx +++ b/guides/stellar-fees.mdx @@ -167,3 +167,12 @@ To ensure smooth operations in production apps, implement these heuristics: 2. **Prioritize Batching**: Always batch transfers when sending payments to multiple recipients. Calling `batch_send` reduces transaction fee costs and cuts ledger read overhead. 3. **Set a Surge Pricing Margin**: In production, configure your fee estimator to add a margin (e.g., 20% to 50%) to the suggested base fee during network congestion to prevent transactions from getting stuck in the transaction queue. 4. **Account for Rent Renewal**: For stateful registries, monitor the Time-to-Live (TTL) of storage entries. Implement automated routines to call the `extend_ttl` endpoint on contracts to prevent crucial records (like registrant meta-addresses) from being archived. + +--- + +## See Also + +- [Reflector Oracle Integration](/guides/integrations/reflector) — plug Reflector into Wraith flows for inline fiat conversion alongside fee estimation +- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production recipes for Stellar agents +- [Stellar Payment Links](/guides/stellar-payment-links) — generate payment links with fiat amounts +- [Stellar Troubleshooting](/guides/stellar-troubleshooting) — fixes for common Stellar and Soroban errors