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 — 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