Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 313 additions & 0 deletions ccipread/ccipread.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
// Copyright 2026 Weald Technology Trading.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0

// Package ccipread implements the client side of ERC-3668 (CCIP Read)
// so that calls into ENS contracts which revert with OffchainLookup are
// transparently followed: the gateway is queried, the callback is invoked,
// and the chain is repeated until a non-revert result is returned.
package ccipread

import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
)

// MaxRedirects bounds the number of OffchainLookup hops a single call may chain.
// Four matches the limit used by ENS-aware libraries (viem, ethers).
const MaxRedirects = 4

// offchainLookupSelector is the first 4 bytes of
// keccak256("OffchainLookup(address,string[],bytes,bytes4,bytes)").
var offchainLookupSelector = []byte{0x55, 0x6f, 0x18, 0x30}

// offchainLookupArgs is the ABI for the OffchainLookup error parameters,
// used to decode the revert payload that follows the 4-byte selector.
var offchainLookupArgs abi.Arguments

// callbackArgs is the ABI for the callback signature
// callbackFunction(bytes response, bytes extraData).
var callbackArgs abi.Arguments

func init() {
addrT, _ := abi.NewType("address", "", nil)
stringArrT, _ := abi.NewType("string[]", "", nil)
bytesT, _ := abi.NewType("bytes", "", nil)
bytes4T, _ := abi.NewType("bytes4", "", nil)
offchainLookupArgs = abi.Arguments{
{Name: "sender", Type: addrT},
{Name: "urls", Type: stringArrT},
{Name: "callData", Type: bytesT},
{Name: "callbackFunction", Type: bytes4T},
{Name: "extraData", Type: bytesT},
}
callbackArgs = abi.Arguments{
{Name: "response", Type: bytesT},
{Name: "extraData", Type: bytesT},
}
}

// OffchainLookup is a decoded OffchainLookup revert.
type OffchainLookup struct {
Sender common.Address
URLs []string
CallData []byte
CallbackFunction [4]byte
ExtraData []byte
}

// Caller is the subset of bind.ContractBackend needed for CCIP-Read calls.
type Caller interface {
bind.ContractCaller
}

// Options tune the CCIP-Read client. A zero value is valid and uses
// http.DefaultClient with the package default for MaxRedirects.
type Options struct {
HTTPClient *http.Client
MaxRedirects int
// BlockNumber pins the eth_call to a specific block. Nil means latest.
BlockNumber *big.Int
}

// Call executes a contract call and follows any OffchainLookup reverts.
// On success it returns the raw bytes returned by the (possibly callback-)
// invoked function. The decoding of those bytes is the caller's job — for
// UniversalResolver.resolve they ABI-decode as (bytes, address); for
// UniversalResolver.reverse they decode as (string, address, address).
func Call(ctx context.Context, backend Caller, to common.Address, data []byte, opts *Options) ([]byte, error) {
if opts == nil {
opts = &Options{}
}
max := opts.MaxRedirects
if max <= 0 {
max = MaxRedirects
}
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}

target := to
callData := data
for hop := 0; hop <= max; hop++ {
out, callErr := backend.CallContract(ctx, ethereum.CallMsg{To: &target, Data: callData}, opts.BlockNumber)
if callErr == nil {
return out, nil
}
lookup, ok := decodeOffchainLookup(callErr)
if !ok {
return nil, callErr
}
// Per ERC-3668 the sender field MUST equal the contract that
// reverted; otherwise the response could be misdirected.
if lookup.Sender != target {
return nil, fmt.Errorf("ccipread: OffchainLookup sender %s does not match call target %s", lookup.Sender.Hex(), target.Hex())
}
response, err := queryGateways(ctx, httpClient, lookup.URLs, lookup.Sender, lookup.CallData)
if err != nil {
return nil, err
}
nextData, err := encodeCallback(lookup.CallbackFunction, response, lookup.ExtraData)
if err != nil {
return nil, err
}
callData = nextData
// target stays the same: the callback lives on `sender`, which we just
// validated equals the previous target.
}
return nil, fmt.Errorf("ccipread: exceeded %d OffchainLookup redirects", max)
}

// DecodeOffchainLookup parses an OffchainLookup revert from raw revert bytes
// (selector + ABI-encoded args). It returns false if the data does not start
// with the OffchainLookup selector.
func DecodeOffchainLookup(revert []byte) (*OffchainLookup, bool) {
if len(revert) < 4 || !bytes.Equal(revert[:4], offchainLookupSelector) {
return nil, false
}
values, err := offchainLookupArgs.Unpack(revert[4:])
if err != nil || len(values) != 5 {
return nil, false
}
sender, ok := values[0].(common.Address)
if !ok {
return nil, false
}
urls, ok := values[1].([]string)
if !ok {
return nil, false
}
callData, ok := values[2].([]byte)
if !ok {
return nil, false
}
callbackFunction, ok := values[3].([4]byte)
if !ok {
return nil, false
}
extraData, ok := values[4].([]byte)
if !ok {
return nil, false
}
return &OffchainLookup{
Sender: sender,
URLs: urls,
CallData: callData,
CallbackFunction: callbackFunction,
ExtraData: extraData,
}, true
}

// decodeOffchainLookup attempts to extract an OffchainLookup payload from a
// CallContract error. Backends expose the revert data via the rpc.DataError
// interface; geth and ethclient both satisfy it.
func decodeOffchainLookup(err error) (*OffchainLookup, bool) {
if err == nil {
return nil, false
}
type dataError interface{ ErrorData() interface{} }
var de dataError
if !errors.As(err, &de) {
return nil, false
}
raw, ok := de.ErrorData().(string)
if !ok {
return nil, false
}
revert, err := hex.DecodeString(strings.TrimPrefix(raw, "0x"))
if err != nil {
return nil, false
}
return DecodeOffchainLookup(revert)
}

func encodeCallback(selector [4]byte, response, extraData []byte) ([]byte, error) {
body, err := callbackArgs.Pack(response, extraData)
if err != nil {
return nil, fmt.Errorf("ccipread: encode callback args: %w", err)
}
out := make([]byte, 0, 4+len(body))
out = append(out, selector[:]...)
out = append(out, body...)
return out, nil
}

// gatewayRequest is the JSON body the spec defines for POST queries.
type gatewayRequest struct {
Data string `json:"data"`
Sender string `json:"sender"`
}

// gatewayResponse is the JSON envelope returned by gateways.
type gatewayResponse struct {
Data string `json:"data"`
}

// queryGateways tries each URL in order and returns the first successful
// response. Per ERC-3668 a 4xx aborts the whole call; a 5xx falls through
// to the next URL.
func queryGateways(ctx context.Context, client *http.Client, urls []string, sender common.Address, data []byte) ([]byte, error) {
if len(urls) == 0 {
return nil, errors.New("ccipread: OffchainLookup returned no gateway URLs")
}
hexData := "0x" + hex.EncodeToString(data)
hexSender := strings.ToLower(sender.Hex())
var lastErr error
for _, raw := range urls {
resp, status, err := queryGateway(ctx, client, raw, hexSender, hexData)
if err == nil {
return resp, nil
}
// 4xx: per spec, abort immediately — request is malformed and other
// gateways will reject it the same way.
if status >= 400 && status < 500 {
return nil, err
}
lastErr = err
}
if lastErr == nil {
lastErr = errors.New("ccipread: no gateway URL succeeded")
}
return nil, lastErr
}

func queryGateway(ctx context.Context, client *http.Client, url, sender, data string) ([]byte, int, error) {
hasData := strings.Contains(url, "{data}")
hasSender := strings.Contains(url, "{sender}")
var req *http.Request
var err error
if hasData {
// GET form: substitute placeholders directly into the URL.
populated := strings.ReplaceAll(url, "{data}", data)
if hasSender {
populated = strings.ReplaceAll(populated, "{sender}", sender)
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, populated, nil)
} else {
body, jerr := json.Marshal(gatewayRequest{Data: data, Sender: sender})
if jerr != nil {
return nil, 0, jerr
}
req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
}
if err != nil {
return nil, 0, err
}
if client.Timeout == 0 {
// Best-effort timeout when the caller didn't supply one. Without this
// a wedged gateway would block resolution indefinitely.
ctxT, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
req = req.WithContext(ctxT)
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("ccipread: gateway %s: %w", url, err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, resp.StatusCode, fmt.Errorf("ccipread: gateway %s: HTTP %d: %s", url, resp.StatusCode, truncate(string(bodyBytes), 256))
}
var parsed gatewayResponse
if err := json.Unmarshal(bodyBytes, &parsed); err != nil {
return nil, resp.StatusCode, fmt.Errorf("ccipread: gateway %s: invalid JSON: %w", url, err)
}
out, err := hex.DecodeString(strings.TrimPrefix(parsed.Data, "0x"))
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("ccipread: gateway %s: invalid hex in response.data: %w", url, err)
}
return out, resp.StatusCode, nil
}

func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
1 change: 1 addition & 0 deletions contracts/universalresolver/contract.abi
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"contract ENS","name":"ens","type":"address"},{"internalType":"contract IGatewayProvider","name":"batchGatewayProvider","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"bytes","name":"dns","type":"bytes"}],"name":"DNSDecodingFailed","type":"error"},{"inputs":[{"internalType":"string","name":"ens","type":"string"}],"name":"DNSEncodingFailed","type":"error"},{"inputs":[],"name":"EmptyAddress","type":"error"},{"inputs":[{"internalType":"uint16","name":"status","type":"uint16"},{"internalType":"string","name":"message","type":"string"}],"name":"HttpError","type":"error"},{"inputs":[],"name":"InvalidBatchGatewayResponse","type":"error"},{"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"string[]","name":"urls","type":"string[]"},{"internalType":"bytes","name":"callData","type":"bytes"},{"internalType":"bytes4","name":"callbackFunction","type":"bytes4"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"OffchainLookup","type":"error"},{"inputs":[{"internalType":"uint256","name":"offset","type":"uint256"},{"internalType":"uint256","name":"length","type":"uint256"}],"name":"OffsetOutOfBoundsError","type":"error"},{"inputs":[{"internalType":"bytes","name":"errorData","type":"bytes"}],"name":"ResolverError","type":"error"},{"inputs":[{"internalType":"bytes","name":"name","type":"bytes"},{"internalType":"address","name":"resolver","type":"address"}],"name":"ResolverNotContract","type":"error"},{"inputs":[{"internalType":"bytes","name":"name","type":"bytes"}],"name":"ResolverNotFound","type":"error"},{"inputs":[{"internalType":"string","name":"primary","type":"string"},{"internalType":"bytes","name":"primaryAddress","type":"bytes"}],"name":"ReverseAddressMismatch","type":"error"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"}],"name":"UnsupportedResolverProfile","type":"error"},{"inputs":[],"name":"batchGatewayProvider","outputs":[{"internalType":"contract IGatewayProvider","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"components":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"call","type":"bytes"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"flags","type":"uint256"}],"internalType":"struct CCIPBatcher.Lookup[]","name":"lookups","type":"tuple[]"},{"internalType":"string[]","name":"gateways","type":"string[]"}],"internalType":"struct CCIPBatcher.Batch","name":"batch","type":"tuple"}],"name":"ccipBatch","outputs":[{"components":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"call","type":"bytes"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"flags","type":"uint256"}],"internalType":"struct CCIPBatcher.Lookup[]","name":"lookups","type":"tuple[]"},{"internalType":"string[]","name":"gateways","type":"string[]"}],"internalType":"struct CCIPBatcher.Batch","name":"","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"ccipBatchCallback","outputs":[{"components":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"call","type":"bytes"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"flags","type":"uint256"}],"internalType":"struct CCIPBatcher.Lookup[]","name":"lookups","type":"tuple[]"},{"internalType":"string[]","name":"gateways","type":"string[]"}],"internalType":"struct CCIPBatcher.Batch","name":"batch","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"ccipReadCallback","outputs":[],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"name","type":"bytes"}],"name":"findResolver","outputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"bytes32","name":"","type":"bytes32"},{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"registry","outputs":[{"internalType":"contract ENS","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"name","type":"bytes"}],"name":"requireResolver","outputs":[{"components":[{"internalType":"bytes","name":"name","type":"bytes"},{"internalType":"uint256","name":"offset","type":"uint256"},{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"address","name":"resolver","type":"address"},{"internalType":"bool","name":"extended","type":"bool"}],"internalType":"struct AbstractUniversalResolver.ResolverInfo","name":"info","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"name","type":"bytes"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"resolve","outputs":[{"internalType":"bytes","name":"","type":"bytes"},{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"resolveBatchCallback","outputs":[],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"resolveCallback","outputs":[{"internalType":"bytes","name":"","type":"bytes"},{"internalType":"address","name":"","type":"address"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"resolveDirectCallback","outputs":[],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"","type":"bytes"}],"name":"resolveDirectCallbackError","outputs":[],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"bytes","name":"name","type":"bytes"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"string[]","name":"gateways","type":"string[]"}],"name":"resolveWithGateways","outputs":[{"internalType":"bytes","name":"result","type":"bytes"},{"internalType":"address","name":"resolver","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"resolver","type":"address"},{"internalType":"bytes","name":"name","type":"bytes"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"string[]","name":"gateways","type":"string[]"}],"name":"resolveWithResolver","outputs":[{"internalType":"bytes","name":"","type":"bytes"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"lookupAddress","type":"bytes"},{"internalType":"uint256","name":"coinType","type":"uint256"}],"name":"reverse","outputs":[{"internalType":"string","name":"","type":"string"},{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"reverseAddressCallback","outputs":[{"internalType":"string","name":"primary","type":"string"},{"internalType":"address","name":"resolver","type":"address"},{"internalType":"address","name":"reverseResolver","type":"address"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"bytes","name":"response","type":"bytes"},{"internalType":"bytes","name":"extraData","type":"bytes"}],"name":"reverseNameCallback","outputs":[{"internalType":"string","name":"primary","type":"string"},{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"lookupAddress","type":"bytes"},{"internalType":"uint256","name":"coinType","type":"uint256"},{"internalType":"string[]","name":"gateways","type":"string[]"}],"name":"reverseWithGateways","outputs":[{"internalType":"string","name":"primary","type":"string"},{"internalType":"address","name":"resolver","type":"address"},{"internalType":"address","name":"reverseResolver","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}]
Loading