advanced 45 min

Price Oracles

Publish and consume on-chain pricing data using OracleSet transactions. Aggregate prices from multiple oracle feeds and manage oracle lifecycle.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Explain what a native XRPL price oracle is and why on-chain price feeds need aggregation.
  • Publish a price feed on testnet with an OracleSet transaction, encoding Provider/AssetClass and the PriceDataSeries correctly.
  • Update an oracle with a new price and read the value back from the ledger.
  • Aggregate prices across multiple oracles with get_aggregate_price and apply outlier trimming.
  • Clean up an oracle and reclaim its reserve with OracleDelete.
Complete this module by peer review. Jump to assessment

Overview

Smart contracts and DeFi apps are blind to the outside world. The ledger knows that 13 XRP moved from one account to another, but it has no idea what 13 XRP is worth in dollars, euros, or BTC. That price lives off-chain — on exchanges, in market data APIs — and someone has to carry it onto the ledger. That someone is a price oracle.

The XRP Ledger has price oracles built in as a native object type. You don't deploy a contract or trust a third-party bridge: an account publishes a price with an OracleSet transaction, and anyone can read it back straight from the ledger. By the end of this module you'll have a single TypeScript file that stands up an XRP/USD oracle on testnet, updates the price, reads the value back, aggregates across several oracles, and tears the oracle down again.

Everything runs against the testnet, so the XRP is free and you can experiment without consequence. We'll use xrpl.js for the connection, signing, and submission — the same client you used for payments.

A quick mental model before we touch code: an oracle is a small ledger object owned by your account. It carries some metadata (who's providing the data, what asset class it covers, when it was last updated) and a list of price points — each one a base asset, a quote asset, and a scaled integer price. Updating the oracle is just submitting another OracleSet to the same OracleDocumentID.

Setup

Create a project and install the client:

npm init -y
npm i xrpl
npm i -D typescript tsx @types/node

Create oracle.ts, connect to testnet, and fund a wallet to own the oracle. Creating an oracle object raises your account's owner reserve (it's an owned object), so the account needs to be funded and active.

import * as xrpl from 'xrpl'

const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')

async function main() {
  await client.connect()

  const { wallet: owner } = await client.fundWallet()
  console.log('Oracle owner:', owner.address)

  // ... everything below goes here ...

  await client.disconnect()
}

main()

Run it any time with npx tsx oracle.ts.

Encoding the metadata

Two oracle fields — Provider and AssetClass — are arbitrary identifiers that the ledger stores as hex-encoded strings, not plain text. Provider says who's behind the feed (e.g. the name of your data source); AssetClass is a short descriptor of the market it covers (e.g. currency, commodity, index). You can't pass them as readable strings — convert them first.

const provider = xrpl.convertStringToHex('provider')   // "70726F7669646572"
const assetClass = xrpl.convertStringToHex('currency')  // "63757272656E6379"

Pick whatever values describe your feed. The ledger doesn't validate that they're "true" — they're documentation for whoever consumes the oracle, so make them honest and meaningful.

Publish a price with OracleSet

Now the core transaction. An OracleSet creates the oracle the first time you submit it for a given OracleDocumentID, and updates it every time after that. The OracleDocumentID is just a number you choose to name this particular feed under your account — 34 is as good as any.

The actual prices live in PriceDataSeries, an array where each entry wraps a PriceData object. Each PriceData names a BaseAsset, a QuoteAsset, an integer AssetPrice, and a Scale. There are no floating-point numbers on the ledger, so a price is expressed as an integer plus the number of decimal places to shift it: with Scale: 2, an AssetPrice of 740 means 7.40 — that is, 740 / 10^2 = 740 / 100 = 7.40. So "1 XRP = $7.40" is encoded as base XRP, quote USD, price 740, scale 2.

const oracleSetTx: xrpl.OracleSet = {
  TransactionType: 'OracleSet',
  Account: owner.classicAddress,
  OracleDocumentID: 34,
  Provider: provider,
  AssetClass: assetClass,
  LastUpdateTime: Math.floor(Date.now() / 1000),
  PriceDataSeries: [
    {
      PriceData: {
        BaseAsset: 'XRP',
        QuoteAsset: 'USD',
        AssetPrice: 740,
        Scale: 2,
      },
    },
  ],
}

const result = await client.submitAndWait(oracleSetTx, {
  autofill: true,
  wallet: owner,
})

const code = result.result.meta?.TransactionResult
if (code !== 'tesSUCCESS') throw new Error(`OracleSet failed: ${code}`)
console.log('Oracle published:', result.result.hash)

LastUpdateTime is a Unix timestamp in seconds (Math.floor(Date.now() / 1000)), and it must move forward on every update — the ledger rejects an OracleSet whose timestamp isn't newer than the last one and is roughly current. That rule is the point of an oracle: it guarantees a stale feed is detectable. As always, the result code is the source of truth — only tesSUCCESS means the oracle is live.

Update the price

Updating is the same transaction with a fresher price and a newer LastUpdateTime. Say the market moved to $7.55:

const updateTx: xrpl.OracleSet = {
  ...oracleSetTx,
  LastUpdateTime: Math.floor(Date.now() / 1000),
  PriceDataSeries: [
    { PriceData: { BaseAsset: 'XRP', QuoteAsset: 'USD', AssetPrice: 755, Scale: 2 } },
  ],
}

await client.submitAndWait(updateTx, { autofill: true, wallet: owner })

Because it targets the same OracleDocumentID, this doesn't create a second oracle — it overwrites the existing one. You can carry multiple pairs in a single oracle by adding more PriceData entries to the array. Note the reserve angle: an oracle holding more than five token pairs counts as an extra owned object, so make sure the account has the reserve headroom before you load it up.

Read the price back

Don't trust the transaction code alone — read the object from the ledger. The ledger_entry request fetches the oracle by its owner account and document ID:

const entry = await client.request({
  command: 'ledger_entry',
  oracle: {
    account: owner.classicAddress,
    oracle_document_id: 34,
  },
  ledger_index: 'validated',
})

const series = (entry.result.node as any).PriceDataSeries
console.log(JSON.stringify(series, null, 2))

What comes back is the same PriceDataSeries you submitted. Remember the price is still scaled: an AssetPrice of 755 with Scale: 2 is 7.55, so a consumer divides by 10 ** Scale to get the real number. (In stored ledger objects the AssetPrice is hex-encoded, so a robust reader parses it with Number('0x' + hex) before scaling.)

Aggregate across oracles

A single feed is a single point of failure — and a single point of manipulation. If your app prices liquidations or collateral off one account's number, whoever controls that account controls your app. The fix is to read several oracles and combine them. The ledger does this for you with get_aggregate_price.

You pass the asset pair and a list of oracles to consult (each identified by account + oracle_document_id), and the ledger returns the mean, median, and a trimmed mean. trim is the percentage of outliers to drop from each end before averaging — trim: 20 throws away the top and bottom 20%, so a single wildly-off feed can't drag the result.

const agg = await client.request({
  command: 'get_aggregate_price',
  base_asset: 'XRP',
  quote_asset: 'USD',
  trim: 20,
  ledger_index: 'current',
  oracles: [
    { account: owner.classicAddress, oracle_document_id: 34 },
    // ...add more { account, oracle_document_id } here as you collect feeds
  ],
})

console.log(agg.result.entire_set)   // mean + size across all oracles
console.log(agg.result.median)       // the median price
console.log(agg.result.trimmed_set)  // mean after dropping outliers

With one oracle in the list you'll get that oracle's price back as the "aggregate" — useful for wiring up the call. The value of aggregation only appears once you have several independent providers feeding the same pair, which is exactly how production XRPL price oracles are meant to be consumed: read many, trim outliers, trust the median.

There's a second safety dimension beyond aggregation: freshness. A price that was correct an hour ago can be dangerously wrong now. Because every oracle carries a LastUpdateTime, a careful consumer doesn't just read the number — it checks how old that number is and refuses to act on a feed that's gone stale. Aggregation defends you against a wrong feed; freshness checks defend you against a dead one. Real DeFi logic on the ledger leans on both: pull several oracles, drop the outliers, and ignore anything whose timestamp is too far in the past.

Clean up with OracleDelete

An oracle is an owned object, so it holds part of your account's reserve until you remove it. When a feed is retired, OracleDelete deletes the object and frees the reserve:

const deleteTx: xrpl.OracleDelete = {
  TransactionType: 'OracleDelete',
  Account: owner.classicAddress,
  OracleDocumentID: 34,
}

const del = await client.submitAndWait(deleteTx, { autofill: true, wallet: owner })
if (del.result.meta?.TransactionResult !== 'tesSUCCESS') {
  throw new Error('OracleDelete failed')
}
console.log('Oracle removed, reserve reclaimed')

Managing this lifecycle — create, update, delete — is part of running a feed responsibly: don't leave stale oracles on the ledger eating reserve and confusing consumers.

Check

You're done when you have a single script that, in one run, funds an owner wallet, publishes an XRP/USD oracle with OracleSet (confirming tesSUCCESS), updates it with a new price, reads the value back from the ledger with ledger_entry, and aggregates it via get_aggregate_price. Capture the validated OracleSet transaction hash and confirm it on the Testnet Explorer. Submit a repo or gist containing your oracle.ts (and the broadcast tx hash) for a peer to verify.

Assignments

0 of 2 complete