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:
- 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.
- Scale human USD numbers to 18-decimal fixed point, matching Chainlink’s convention for USD-denominated feeds.
- 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
The task fires on the next 5-minute boundary. Watch it:
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