> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a multi-chain NAV oracle

> Publish a tokenized fund's Net Asset Value to multiple chains on a schedule, with a Chainlink-compatible on-chain interface and an operator kill-switch

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

```mermaid theme={null}
flowchart LR
    A[Cron Trigger] -->|every 5 min| B[Compose Task]
    B -->|GET| C[Custodian Endpoint]
    C -->|"{totalNav, cash, tbills, repo, asOf, ripcord}"| B
    B -->|updateNav| D[ReserveAggregator on Base Sepolia]
    B -->|updateNav| E[ReserveAggregator on Arbitrum Sepolia]
```

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

* [Goldsky CLI installed](/installation)
* [Foundry](https://book.getfoundry.sh/getting-started/installation) for contract deploys
* Testnet ETH on Base Sepolia and Arbitrum Sepolia to deploy the contracts

## Project structure

```text theme={null}
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:

```bash theme={null}
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:

```typescript theme={null}
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:

```solidity theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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.

<Tip>
  `--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.
</Tip>

## 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:

```typescript theme={null}
const BASE_SEPOLIA_AGGREGATOR     = "0x..."; // from step 5
const ARBITRUM_SEPOLIA_AGGREGATOR = "0x..."; // from step 5
```

## Step 7: Deploy to Goldsky

```bash theme={null}
goldsky compose deploy
```

The task fires on the next 5-minute boundary. Watch it:

```bash theme={null}
goldsky compose logs
```

You should see a line like:

```text theme={null}
Published Example RWA Fund I NAV=$50,825,000 — base:ok, arb:ok
```

Verify on-chain by reading back `latestRoundData()`:

```bash theme={null}
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:

```json theme={null}
{
  "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:

```typescript theme={null}
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`:

```yaml theme={null}
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

* [Compose introduction](/compose/introduction)
* [Task triggers](/compose/task-triggers)
* [EVM wallets & gas sponsoring](/compose/context/evm/wallets)
* [Chainlink `AggregatorV3Interface` reference](https://docs.chain.link/data-feeds/api-reference#aggregatorv3interface)
* [GitHub repository](https://github.com/goldsky-io/documentation-examples/tree/main/compose/nav-oracle)
