Skip to main content
This guide walks you through building a Proof-of-Reserves / NAV oracle for a tokenized RWA fund. A single Compose task runs every 5 minutes, fetches a structured NAV bundle from a custodian endpoint, and publishes it to ReserveAggregator contracts on Base Sepolia and Arbitrum Sepolia in one run. The on-chain contract implements AggregatorV3Interface, so any existing Chainlink consumer can read it unchanged.

How it works

Each cycle:
  1. Fetch the NAV bundle from a custodian JSON endpoint. If the response carries ripcord: true, the task logs a skip message and returns without publishing.
  2. Scale human USD numbers to 18-decimal fixed point, matching Chainlink’s convention for USD-denominated feeds.
  3. Publish to both chains independently via Promise.allSettled. A failure on one chain does not block the other. The next cron cycle reconciles.

Prerequisites

Project structure

Step 1: Set up the project

Clone the example repository:

Step 2: Understand the task

nav-oracle.ts fetches the bundle, validates the ripcord, and publishes to both chains:

Key Compose features used

  • Multi-chain writes from a single task: one wallet.writeContract call per chain, issued in parallel via Promise.allSettled.
  • sponsorGas: true: the publisher wallet is a Compose-managed Privy wallet whose transactions are gas-sponsored by Goldsky. No ETH needed on the publisher ever.
  • context.fetch: retries the custodian call with exponential backoff before surfacing a failure.
  • Ripcord pattern: the data source itself carries a boolean kill-switch the operator can flip out-of-band. Useful when the upstream has a known issue and you want the oracle to pause without a redeploy.

Step 3: Understand the contract

ReserveAggregator.sol is a single-operator, AggregatorV3Interface-compatible publisher. The full bundle is stored in one struct; the scalar totalNav is exposed via the standard Chainlink reader so existing consumers work unchanged:
Only the publisher address set in the constructor can call updateNav. That address is your Compose-managed wallet.

Step 4: Pre-create the publisher wallet

Compose’s wallet-create command provisions the named wallet in the cloud and prints its address, so you can pass that address as the publisher constructor argument before you deploy the contracts:
The command prints the wallet address to stdout. Save it for the next step.

Step 5: Deploy ReserveAggregator on both chains

Deploy to Base Sepolia:
And Arbitrum Sepolia:
Record both deployed addresses.
--broadcast must come before --constructor-args: forge treats --constructor-args as variadic, so any flag that follows it gets consumed as another positional argument and the transaction is never sent.

Step 6: Wire in the addresses

Open src/tasks/nav-oracle.ts and replace the two address constants near the top with the ones you just deployed:

Step 7: Deploy to Goldsky

The task fires on the next 5-minute boundary. Watch it:
You should see a line like:
Verify on-chain by reading back latestRoundData():
The answer field is the total NAV scaled to 18 decimals; updatedAt is the custodian’s asOf timestamp.

Customization

Swap the data source

Change CUSTODIAN_URL to your own endpoint. Your API must return:
Amounts are human-readable USD. The task scales to 18 decimals before writing on-chain.

Add or swap chains

Add another wallet.writeContract(...) inside the Promise.allSettled block, targeting a different evm.chains.<name> and contract address:
Deploy a matching ReserveAggregator to that chain first.

Change the publish cadence

Real Proof-of-Reserves / NAV feeds typically publish hourly or daily. Change the cron expression in compose.yaml:

Use the ripcord

Any host can flip the kill-switch by returning "ripcord": true from the custodian endpoint. The next task run logs Ripcord engaged … and skips the publish without erroring, so Compose’s retry logic is not triggered. Flip it back to false and publishing resumes on the following cycle.

Rotate the publisher

If you need to re-create the Compose wallet or move to a different key, call setPublisher(newAddress) from the current publisher. The old wallet loses updateNav permissions; only the new one can publish.

Resources