On this page

Relay a read with Omnilink

Relay one approved source-chain view result by encoding its exact calldata, quoting the destination message, and calling sendRead.

1. Choose the read path

Omnilink identifies a read by source chain, target, and the hash of the complete calldata. Encode arguments exactly as they will be sent.

For a typed price snapshot:

bytes memory callData = abi.encodeCall(
    priceAdapter.readPrice,
    (asset)
);

For a direct Chainlink single-value feed:

bytes memory callData = abi.encodeWithSignature(
    "latestRoundData()"
);

For an ERC-4626 aggregate:

bytes memory callData = abi.encodeCall(
    vaultAdapter.readVault,
    (vault)
);

2. Register it on the source

The Omnilink owner registers the exact path before relayers can use it:

bytes32 readId = omnilink.configureRead(
    address(priceAdapter),
    callData,
    address(0), // optional IOmnilinkReadValidator
    500_000,    // source staticcall gas
    512,        // maximum returned bytes
    true
);

Changing an argument changes the read ID and requires a separate registration. The owner can disable a registration by calling configureRead again with the same target and calldata and enabled = false.

Use a source validator when the target cannot enforce its own output policy. Typed adapters already reject disabled registrations, nonpositive Chainlink answers, missing rounds, future/stale feed timestamps, metadata drift, and invalid ERC-4626 conversions.

3. Preview and send

Anyone can execute the source read locally before paying for delivery:

bytes memory result = omnilink.previewRead(
    address(priceAdapter),
    callData
);

uint256 totalFee = omnilink.quoteRead(
    destinationEid,
    address(priceAdapter),
    callData,
    options
);

MessagingReceipt memory receipt = omnilink.sendRead{value: totalFee}(
    destinationEid,
    address(priceAdapter),
    callData,
    options
);

quoteRead includes the LayerZero fee and Omnilink's fixed protocol fee. The source result is evaluated again inside sendRead, so production relayers should quote immediately before sending and handle source data changing between simulation and submission.

Each call sends to one destination. Sending the same aggregate to three destinations requires three sendRead transactions and three protocol fees.

4. Read and decode the destination result

Compute the same ID using the source chain ID:

bytes32 readId = destinationOmnilink.readId(
    sourceChainId,
    address(priceAdapter),
    callData
);

destinationOmnilink.requireFresh(readId, 30 minutes);
Omnilink.ReadResult memory stored = destinationOmnilink.getResult(readId);

OmnilinkChainlinkPriceAdapter.PriceSnapshot memory snapshot = abi.decode(
    stored.result,
    (OmnilinkChainlinkPriceAdapter.PriceSnapshot)
);

Before consuming the answer, verify the expected schema version, asset, quote or currency ID, feed, and immutable configHash. Feed timestamp validation and destination delivery freshness are separate checks; use both.

For a single static ABI word, readWord, readUint256, and readInt256 avoid decoding the complete result. Typed aggregate snapshots should be decoded as their declared struct.

Optional: fulfill an OMNI reward

Requests live only on the destination chain. They do not invoke or notify the source chain.

The requester first approves OMNI and locks a custom amount:

omni.approve(address(destinationOmnilink), rewardAmount);

bytes32 requestedId = destinationOmnilink.requestRead(
    sourceChainId,
    address(priceAdapter),
    callData,
    rewardAmount
);

Offchain relayers discover ReadRequested, verify the exact path and economics, then call the normal source-chain sendRead. When the matching result arrives, the destination credits the reward to the source transaction's msg.sender:

uint256 amount = destinationOmnilink.claimOmniRewards(recipient);

The adapter must remain the staticcall target, not the caller of sendRead. Otherwise the adapter address would be recorded as readSender instead of the relayer.

The reward is credited to the same 20-byte readSender address on the destination. An EOA relayer can claim with the same key. A smart-contract relayer must also have callable code at that destination address that can invoke claimOmniRewards.

Cancellation and delivery races

An unanswered request becomes cancelable one hour after creation:

destinationOmnilink.cancelReadRequest(requestedId);

Cancellation returns the locked OMNI to the requester. A read already in flight still updates the destination after cancellation but pays no reward.

Before fulfilling a request, relayers should:

  1. Confirm rewardLocks(readId).status is Locked.
  2. Compare the reward with source gas, LayerZero fee, and Omnilink protocol fee.
  3. Leave enough time before the one-hour cancellation boundary.
  4. Preview the registered source call.
  5. Re-check the destination lock immediately before submitting.

No on-chain mechanism can remove the final cross-chain cancellation race because the destination request never controls the unrelated source send.

Operator checklist

  • Use the configured Chainlink proxy, not its current aggregator implementation.
  • Register exact adapter calldata separately for every asset or vault.
  • Set maxResultBytes to the expected schema size with a small explicit margin.
  • Use LayerZero options that buy enough destination execution gas.
  • Do not relay unchanged data more often than its heartbeat requires unless a reward justifies it.
  • Track ReadSent, LayerZero delivery, and ReadReceived by GUID.
  • Treat adapter aggregation as structured transport, not as an economic validity guarantee.
Experimental Beta is Live-Learn more about the Pilot