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

nav-oracle/
├── compose.yaml                  # 1 cron task, 2 chains
├── mock-custodian.json           # Default data source (swap for your API)
├── contracts/
│   └── ReserveAggregator.sol     # AggregatorV3Interface-compatible publisher
└── src/
    ├── lib/
    │   └── scaling.ts            # USD → 18-decimal bigint helper
    └── tasks/
        └── nav-oracle.ts         # The cron task

Step 1: Set up the project

Clone the example repository:
git clone https://github.com/goldsky-io/documentation-examples.git
cd documentation-examples/compose/nav-oracle

Step 2: Understand the task

nav-oracle.ts fetches the bundle, validates the ripcord, and publishes to both chains:
import type { TaskContext } from "compose";
import { toScaled18 } from "../lib/scaling";

const CUSTODIAN_URL =
  "https://raw.githubusercontent.com/goldsky-io/documentation-examples/main/compose/nav-oracle/mock-custodian.json";

const BASE_SEPOLIA_AGGREGATOR     = "0x8099A30Ac752f86C77A0e0210085a908ba6d02fE";
const ARBITRUM_SEPOLIA_AGGREGATOR = "0x02D9Df62B7AED15739D638B92BAcEA2ce4Cb3d70";

export async function main(context: TaskContext) {
  const { fetch, evm } = context;

  // sponsorGas: true lets Goldsky pay gas for every write, so the publisher
  // wallet never needs to be funded.
  const wallet = await evm.wallet({
    name: "nav-oracle-publisher",
    sponsorGas: true,
  });

  const bundle = await fetch<CustodianResponse>(CUSTODIAN_URL, {
    max_attempts: 3,
    initial_interval_ms: 1000,
    backoff_factor: 2,
  });

  // Ripcord: operator kill-switch. Skip cleanly — not an error.
  if (bundle.ripcord) {
    console.log(`Ripcord engaged for ${bundle.accountName} — skipping publish.`);
    return { success: true, skipped: "ripcord" };
  }

  const args = [
    toScaled18(bundle.cash),
    toScaled18(bundle.tbills),
    toScaled18(bundle.repo),
    toScaled18(bundle.totalNav),
    BigInt(Math.floor(new Date(bundle.asOf).getTime() / 1000)),
  ];

  const signature = "updateNav(uint256,uint256,uint256,uint256,uint64)";

  const results = await Promise.allSettled([
    wallet.writeContract(evm.chains.baseSepolia,      BASE_SEPOLIA_AGGREGATOR,     signature, args),
    wallet.writeContract(evm.chains.arbitrumSepolia, ARBITRUM_SEPOLIA_AGGREGATOR, signature, args),
  ]);

  // ... (summarize per-chain results, throw only if both failed)
}

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:
function latestRoundData()
    external
    view
    returns (
        uint80  roundId,
        int256  answer,       // totalNav scaled to 18 decimals
        uint256 startedAt,    // asOf from the bundle
        uint256 updatedAt,
        uint80  answeredInRound
    );

function latestBundle() external view returns (NavBundle memory);

function updateNav(
    uint256 cash,
    uint256 tbills,
    uint256 repo,
    uint256 totalNav,
    uint64  asOf
) external; // onlyPublisher
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:
goldsky compose wallet create nav-oracle-publisher --env cloud
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:
forge create contracts/ReserveAggregator.sol:ReserveAggregator \
  --rpc-url https://sepolia.base.org \
  --private-key $PRIVATE_KEY \
  --broadcast --root . \
  --constructor-args 0xYOUR_PUBLISHER_ADDRESS "Example RWA Fund I NAV / USD"
And Arbitrum Sepolia:
forge create contracts/ReserveAggregator.sol:ReserveAggregator \
  --rpc-url https://sepolia-rollup.arbitrum.io/rpc \
  --private-key $PRIVATE_KEY \
  --broadcast --root . \
  --constructor-args 0xYOUR_PUBLISHER_ADDRESS "Example RWA Fund I NAV / USD"
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:
const BASE_SEPOLIA_AGGREGATOR     = "0x..."; // from step 5
const ARBITRUM_SEPOLIA_AGGREGATOR = "0x..."; // from step 5

Step 7: Deploy to Goldsky

goldsky compose deploy
The task fires on the next 5-minute boundary. Watch it:
goldsky compose logs
You should see a line like:
Published Example RWA Fund I NAV=$50,825,000 — base:ok, arb:ok
Verify on-chain by reading back latestRoundData():
cast call --rpc-url https://sepolia.base.org 0xYOUR_BASE_ADDRESS \
  "latestRoundData()(uint80,int256,uint256,uint256,uint80)"
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:
{
  "accountName": "Your Fund",
  "asOf": "2026-04-22T14:00:00Z",
  "cash": 125000.00,
  "tbills": 42500000.00,
  "repo": 8200000.00,
  "totalNav": 50825000.00,
  "ripcord": false
}
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:
wallet.writeContract(evm.chains.optimismSepolia, OP_SEPOLIA_AGGREGATOR, signature, args),
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:
triggers:
  - type: "cron"
    expression: "0 * * * *"   # every hour

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