diff --git a/.gitignore b/.gitignore index 8d09c99..72c18f8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ coverage.html # Local TODO TODO.md + +# IntelliJ + friends. +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index aa4a4c5..868c873 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ [![Tag](https://img.shields.io/github/tag/wealdtech/go-ens.svg)](https://github.com/wealdtech/go-ens/releases/) [![License](https://img.shields.io/github/license/wealdtech/go-ens.svg)](LICENSE) -[![GoDoc](https://godoc.org/github.com/wealdtech/go-ens?status.svg)](https://godoc.org/github.com/wealdtech/go-ens) +[![GoDoc](https://pkg.go.dev/badge/github.com/wealdtech/go-ens/v4.svg)](https://pkg.go.dev/github.com/wealdtech/go-ens/v4) [![Travis CI](https://img.shields.io/travis/wealdtech/go-ens.svg)](https://travis-ci.org/wealdtech/go-ens) [![codecov.io](https://img.shields.io/codecov/c/github/wealdtech/go-ens.svg)](https://codecov.io/github/wealdtech/go-ens) [![Go Report Card](https://goreportcard.com/badge/github.com/wealdtech/go-ens)](https://goreportcard.com/report/github.com/wealdtech/go-ens) Go module to simplify interacting with the [Ethereum Name Service](https://ens.domains/) contracts. +v4 resolves names through the ENS [Universal Resolver](https://docs.ens.domains/web/ensv2-readiness/), so ENSIP-10 wildcards and ERC-3668 CCIP-Read work out of the box. Callers that need the previous registry-walking behaviour should stay on `github.com/wealdtech/go-ens/v3`. + ## Table of Contents @@ -23,7 +25,7 @@ Go module to simplify interacting with the [Ethereum Name Service](https://ens.d `go-ens` is a standard Go module which can be installed with: ```sh -go get github.com/wealdtech/go-ens/v3 +go get github.com/wealdtech/go-ens/v4 ``` ## Usage @@ -35,21 +37,21 @@ go get github.com/wealdtech/go-ens/v3 The most commonly-used feature of ENS is resolution: converting an ENS name to an Ethereum address. `go-ens` provides a simple call to allow this: ```go -address, err := ens.Resolve(client, domain) +address, err := ens.Resolve(ctx, client, domain) ``` -where `client` is a connection to an Ethereum client and `domain` is the fully-qualified name you wish to resolve (e.g. `foo.mydomain.eth`) (full examples for using this are given in the [Example](#Example) section below). +where `ctx` is a `context.Context` (honoured for cancellation and deadlines through any CCIP-Read hops), `client` is a connection to an Ethereum client, and `domain` is the fully-qualified name you wish to resolve (e.g. `foo.mydomain.eth`) (full examples for using this are given in the [Example](#Example) section below). The reverse process, converting an address to an ENS name, is just as simple: ```go -domain, err := ens.ReverseResolve(client, address) +domain, err := ens.ReverseResolve(ctx, client, address) ``` -Note that if the address does not have a reverse resolution this will return "". If you just want a string version of an address for on-screen display then you can use `ens.Format()`, for example: +If the address has no primary name set this returns an error (`"no resolution"`). If you just want a string version of an address for on-screen display then you can use `ens.Format()`, for example: ```go -fmt.Printf("The address is %s\n", ens.Format(client, address)) +fmt.Printf("The address is %s\n", ens.Format(ctx, client, address)) ``` This will carry out reverse resolution of the address and print the name if present; if not it will print a formatted version of the address. @@ -70,7 +72,7 @@ Addresses can be set and obtained using the address functions, for example to ge ```go COIN_TYPE_ETHEREUM := uint64(60) -address, err := name.Address(COIN_TYPE_ETHEREUM) +address, err := name.Address(ctx, COIN_TYPE_ETHEREUM) ``` ENS supports addresses for multiple coin types; values of coin types can be found at https://github.com/satoshilabs/slips/blob/master/slip-0044.md @@ -90,13 +92,16 @@ Because subdomains have their own registrars they do not work with the `Name` in package main import ( + "context" "fmt" "github.com/ethereum/go-ethereum/ethclient" - ens "github.com/wealdtech/go-ens/v3" + ens "github.com/wealdtech/go-ens/v4" ) func main() { + ctx := context.Background() + // Replace SECRET with your own access token for this example to work. client, err := ethclient.Dial("https://mainnet.infura.io/v3/SECRET") if err != nil { @@ -105,19 +110,16 @@ func main() { // Resolve a name to an address. domain := "ethereum.eth" - address, err := ens.Resolve(client, domain) + address, err := ens.Resolve(ctx, client, domain) if err != nil { panic(err) } fmt.Printf("Address of %s is %s\n", domain, address.Hex()) // Reverse resolve an address to a name. - reverse, err := ens.ReverseResolve(client, address) + reverse, err := ens.ReverseResolve(ctx, client, address) if err != nil { - panic(err) - } - if reverse == "" { - fmt.Printf("%s has no reverse lookup\n", address.Hex()) + fmt.Printf("%s has no reverse lookup: %v\n", address.Hex(), err) } else { fmt.Printf("Name of %s is %s\n", address.Hex(), reverse) } diff --git a/auctionregistrar.go b/auctionregistrar.go index 552c4f2..adac7df 100644 --- a/auctionregistrar.go +++ b/auctionregistrar.go @@ -22,7 +22,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/auctionregistrar" + "github.com/wealdtech/go-ens/v4/contracts/auctionregistrar" ) // AuctionRegistrar is the structure for the auction registrar contract. diff --git a/baseregistrar.go b/baseregistrar.go index 07a5de1..b016b44 100644 --- a/baseregistrar.go +++ b/baseregistrar.go @@ -23,7 +23,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/baseregistrar" + "github.com/wealdtech/go-ens/v4/contracts/baseregistrar" "golang.org/x/crypto/sha3" ) diff --git a/ccipread/ccipread.go b/ccipread/ccipread.go new file mode 100644 index 0000000..5f869e3 --- /dev/null +++ b/ccipread/ccipread.go @@ -0,0 +1,413 @@ +// 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" + neturl "net/url" + "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 + +// MaxGatewayBodyBytes caps the gateway response body that will be read into +// memory. CCIP-Read responses are small JSON envelopes (a 0x-prefixed hex +// payload), so anything beyond a few hundred kilobytes is either pathological +// or hostile. Cap conservatively at 16 MiB to keep a misbehaving gateway from +// exhausting the caller's heap. +const MaxGatewayBodyBytes = 16 * 1024 * 1024 + +// GatewayHTTPError is returned when a CCIP-Read gateway responds with a +// non-2xx HTTP status. Callers can use errors.As to recover the URL, status, +// and (truncated) body so they can decide whether to retry or surface the +// failure. +// +// This is distinct from the on-chain HTTPGatewayError typed-error in the +// parent ens package, which decodes the UR's HttpError(uint16,string) revert. +type GatewayHTTPError struct { + URL string + Status int + Body string +} + +func (e *GatewayHTTPError) Error() string { + return fmt.Sprintf("ccipread: gateway %s: HTTP %d: %s", e.URL, e.Status, truncate(e.Body, 256)) +} + +// noRedirect is the CheckRedirect policy installed when the caller's HTTP +// client doesn't supply one of its own. ERC-3668 gateways are expected to +// return data directly, so a 3xx is suspicious; reflecting the response back +// instead of following protects against SSRF via Location: redirects to +// internal hosts (cloud metadata services, RFC1918 ranges, etc.). +func noRedirect(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse +} + +// 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 + } + if httpClient.CheckRedirect == nil { + // Defensive copy so we don't mutate the caller's shared client. + cp := *httpClient + cp.CheckRedirect = noRedirect + httpClient = &cp + } + + 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 + } + revert, ok := normalizeErrorData(de.ErrorData()) + if !ok { + return nil, false + } + return DecodeOffchainLookup(revert) +} + +// normalizeErrorData accepts the various concrete types go-ethereum's +// rpc.DataError implementers return for ErrorData(): a 0x-prefixed hex +// string (the JSON-RPC case) or raw bytes (mocked / wrapped backends). +func normalizeErrorData(v interface{}) ([]byte, bool) { + switch d := v.(type) { + case string: + raw, err := hex.DecodeString(strings.TrimPrefix(d, "0x")) + if err != nil { + return nil, false + } + return raw, true + case []byte: + return d, true + default: + return nil, false + } +} + +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) { + // Reject obviously hostile gateway URLs before we even build a request. + // Gateway URLs come from a smart contract (untrusted input), so a malicious + // resolver could return file://, gopher://, etc. By default Go's net/http + // transport rejects unknown schemes, but a caller-supplied custom transport + // might support them; this is defense-in-depth. + parsedURL, perr := neturl.Parse(url) + if perr != nil { + return nil, 0, fmt.Errorf("ccipread: gateway %q: invalid URL: %w", url, perr) + } + switch parsedURL.Scheme { + case "http", "https": + // ok + default: + return nil, 0, fmt.Errorf("ccipread: gateway %q: unsupported URL scheme %q", url, parsedURL.Scheme) + } + 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. Use the smaller + // of the caller's remaining deadline (if any) and 30s so a tight + // per-request deadline isn't extended. + timeout := 30 * time.Second + if deadline, ok := ctx.Deadline(); ok { + if remaining := time.Until(deadline); remaining < timeout { + timeout = remaining + } + } + ctxT, cancel := context.WithTimeout(req.Context(), timeout) + 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(io.LimitReader(resp.Body, MaxGatewayBodyBytes)) + if err != nil { + return nil, resp.StatusCode, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, resp.StatusCode, &GatewayHTTPError{ + URL: url, + Status: resp.StatusCode, + Body: string(bodyBytes), + } + } + // Gate the JSON decode on Content-Type so a misbehaving gateway returning + // HTML or text/plain with a 200 status surfaces as a clean error instead of + // a misleading "invalid JSON". + if ct := resp.Header.Get("Content-Type"); !contentTypeIsJSON(ct) { + return nil, resp.StatusCode, fmt.Errorf("ccipread: gateway %s: unexpected Content-Type %q", url, ct) + } + 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] + "..." +} + +// contentTypeIsJSON reports whether a Content-Type header indicates JSON, +// allowing for media-type parameters like `application/json; charset=utf-8`. +func contentTypeIsJSON(ct string) bool { + if ct == "" { + return false + } + if i := strings.Index(ct, ";"); i >= 0 { + ct = ct[:i] + } + ct = strings.TrimSpace(strings.ToLower(ct)) + return ct == "application/json" +} diff --git a/ccipread/ccipread_test.go b/ccipread/ccipread_test.go new file mode 100644 index 0000000..2842f77 --- /dev/null +++ b/ccipread/ccipread_test.go @@ -0,0 +1,288 @@ +// 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_test + +import ( + "context" + "encoding/hex" + "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wealdtech/go-ens/v4/ccipread" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// mockRevertErr satisfies the rpc.DataError interface used by +// decodeOffchainLookup: ErrorData() returns the raw revert bytes as a +// 0x-prefixed hex string. +type mockRevertErr struct { + data string +} + +func (e *mockRevertErr) Error() string { return "execution reverted" } +func (e *mockRevertErr) ErrorData() interface{} { return e.data } +func (e *mockRevertErr) Unwrap() error { return nil } + +// loopBackend is a bind.ContractCaller that always returns the same revert +// error. It records how many CallContract calls it received. +type loopBackend struct { + revertHex string + calls atomic.Int32 +} + +func (b *loopBackend) CallContract(ctx context.Context, _ ethereum.CallMsg, _ *big.Int) ([]byte, error) { + b.calls.Add(1) + return nil, &mockRevertErr{data: b.revertHex} +} + +func (b *loopBackend) CodeAt(_ context.Context, _ common.Address, _ *big.Int) ([]byte, error) { + return nil, nil +} + +// buildOffchainLookupRevert hand-encodes a valid OffchainLookup revert payload. +// Mirrors the ABI used by the contract: OffchainLookup(address,string[],bytes,bytes4,bytes). +func buildOffchainLookupRevert(t *testing.T, sender common.Address, urls []string) string { + t.Helper() + addrT, _ := abi.NewType("address", "", nil) + stringArrT, _ := abi.NewType("string[]", "", nil) + bytesT, _ := abi.NewType("bytes", "", nil) + bytes4T, _ := abi.NewType("bytes4", "", nil) + args := abi.Arguments{ + {Type: addrT}, {Type: stringArrT}, {Type: bytesT}, {Type: bytes4T}, {Type: bytesT}, + } + callback := [4]byte{0xaa, 0xbb, 0xcc, 0xdd} + body, err := args.Pack(sender, urls, []byte{0x01, 0x02}, callback, []byte{0x03, 0x04}) + require.NoError(t, err) + revert := append([]byte{0x55, 0x6f, 0x18, 0x30}, body...) // OffchainLookup selector + return "0x" + hex.EncodeToString(revert) +} + +// --------------------------------------------------------------------------- +// A1 — hop-counter bound: MaxRedirects=4 ⇒ 4 gateway calls, not 5. +// --------------------------------------------------------------------------- + +func TestCall_HopCounterStopsAtMaxRedirects(t *testing.T) { + var gatewayHits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + gatewayHits.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":"0x"}`)) + })) + defer srv.Close() + + target := common.HexToAddress("0x1111111111111111111111111111111111111111") + revert := buildOffchainLookupRevert(t, target, []string{srv.URL}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeded") + // Before the fix the loop allowed 5 gateway queries with MaxRedirects=4. + assert.Equal(t, int32(ccipread.MaxRedirects), gatewayHits.Load(), + "gateway should be hit exactly MaxRedirects times before the loop errors out") + assert.Equal(t, int32(ccipread.MaxRedirects), backend.calls.Load(), + "backend should be called exactly MaxRedirects times") +} + +// --------------------------------------------------------------------------- +// #1 — io.LimitReader caps the response body. +// --------------------------------------------------------------------------- + +func TestCall_LimitReaderTruncatesOversizedBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Pad an otherwise-valid envelope with junk far beyond MaxGatewayBodyBytes. + // LimitReader should cap reading, leaving the JSON unparseable, which we + // surface as a clean error rather than an OOM. + body := make([]byte, ccipread.MaxGatewayBodyBytes+(1<<20)) + for i := range body { + body[i] = 'x' + } + _, _ = w.Write(body) + })) + defer srv.Close() + + target := common.HexToAddress("0x2222222222222222222222222222222222222222") + revert := buildOffchainLookupRevert(t, target, []string{srv.URL}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + assert.NotContains(t, err.Error(), "exceeded", "should fail on JSON, not on hop limit") +} + +// --------------------------------------------------------------------------- +// #3 — JSON unmarshal is gated on Content-Type. +// --------------------------------------------------------------------------- + +func TestCall_RejectsNonJSONContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`0x1234`)) + })) + defer srv.Close() + + target := common.HexToAddress("0x3333333333333333333333333333333333333333") + revert := buildOffchainLookupRevert(t, target, []string{srv.URL}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "Content-Type") +} + +func TestCall_AcceptsJSONWithCharsetParameter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":"0xdeadbeef"}`)) + })) + defer srv.Close() + + target := common.HexToAddress("0x4444444444444444444444444444444444444444") + revert := buildOffchainLookupRevert(t, target, []string{srv.URL}) + // On the SECOND CallContract (after following the lookup), succeed so the + // loop terminates cleanly and we can assert what Call returned. + backend := &succeedAfterOneRevert{revertHex: revert, success: []byte{0x99}} + + out, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.NoError(t, err) + assert.Equal(t, []byte{0x99}, out) +} + +func TestCall_RejectsMissingContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // no Content-Type header + _, _ = w.Write([]byte(`{"data":"0x"}`)) + })) + defer srv.Close() + + target := common.HexToAddress("0x5555555555555555555555555555555555555555") + revert := buildOffchainLookupRevert(t, target, []string{srv.URL}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "Content-Type") +} + +// --------------------------------------------------------------------------- +// A4 — gateway URL scheme allowlist. +// --------------------------------------------------------------------------- + +func TestCall_RejectsNonHTTPScheme(t *testing.T) { + target := common.HexToAddress("0x6666666666666666666666666666666666666666") + revert := buildOffchainLookupRevert(t, target, []string{"file:///etc/passwd"}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported URL scheme") +} + +func TestCall_RejectsGopherScheme(t *testing.T) { + target := common.HexToAddress("0x7777777777777777777777777777777777777777") + revert := buildOffchainLookupRevert(t, target, []string{"gopher://internal.svc/data"}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported URL scheme") +} + +// --------------------------------------------------------------------------- +// A5 — HTTP redirects are not followed by default. +// --------------------------------------------------------------------------- + +func TestCall_DoesNotFollowRedirects(t *testing.T) { + // The "redirect target" server would record a hit if the client followed + // the redirect — we assert it doesn't. + var followed atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + followed.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":"0x"}`)) + })) + defer target.Close() + + // The "gateway" server returns a 302 pointing at the target. + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(http.StatusFound) + })) + defer redirector.Close() + + tgt := common.HexToAddress("0x8888888888888888888888888888888888888888") + revert := buildOffchainLookupRevert(t, tgt, []string{redirector.URL}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, tgt, []byte{0x00}, nil) + require.Error(t, err, "gateway returning a 3xx without data must error, not silently follow") + assert.Equal(t, int32(0), followed.Load(), "redirect target must not be reached") +} + +// --------------------------------------------------------------------------- +// A6 — typed GatewayHTTPError for non-2xx responses. +// --------------------------------------------------------------------------- + +func TestCall_4xxReturnsTypedError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad request"}`)) + })) + defer srv.Close() + + target := common.HexToAddress("0x9999999999999999999999999999999999999999") + revert := buildOffchainLookupRevert(t, target, []string{srv.URL}) + backend := &loopBackend{revertHex: revert} + + _, err := ccipread.Call(context.Background(), backend, target, []byte{0x00}, nil) + require.Error(t, err) + + var typed *ccipread.GatewayHTTPError + require.ErrorAs(t, err, &typed, "expected GatewayHTTPError, got %T: %v", err, err) + assert.Equal(t, 400, typed.Status) + assert.Equal(t, srv.URL, typed.URL) + assert.Contains(t, typed.Body, "bad request") +} + +// --------------------------------------------------------------------------- +// Helper backend that reverts only on the first call, then succeeds. +// --------------------------------------------------------------------------- + +type succeedAfterOneRevert struct { + revertHex string + success []byte + calls atomic.Int32 +} + +func (b *succeedAfterOneRevert) CallContract(_ context.Context, _ ethereum.CallMsg, _ *big.Int) ([]byte, error) { + n := b.calls.Add(1) + if n == 1 { + return nil, &mockRevertErr{data: b.revertHex} + } + return b.success, nil +} + +func (b *succeedAfterOneRevert) CodeAt(_ context.Context, _ common.Address, _ *big.Int) ([]byte, error) { + return nil, nil +} + diff --git a/contracts/universalresolver/contract.abi b/contracts/universalresolver/contract.abi new file mode 100644 index 0000000..14dd2e8 --- /dev/null +++ b/contracts/universalresolver/contract.abi @@ -0,0 +1,65 @@ +[ + { + "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": "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" + } +] diff --git a/contracts/universalresolver/contract.go b/contracts/universalresolver/contract.go new file mode 100644 index 0000000..355832c --- /dev/null +++ b/contracts/universalresolver/contract.go @@ -0,0 +1,246 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package universalresolver + +import ( + "errors" + "math/big" + "strings" + + ethereum "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" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// ContractMetaData contains all meta data concerning the Contract contract. +var ContractMetaData = &bind.MetaData{ + ABI: "[{\"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\":\"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\"}]", +} + +// ContractABI is the input ABI used to generate the binding from. +// Deprecated: Use ContractMetaData.ABI instead. +var ContractABI = ContractMetaData.ABI + +// Contract is an auto generated Go binding around an Ethereum contract. +type Contract struct { + ContractCaller // Read-only binding to the contract + ContractTransactor // Write-only binding to the contract + ContractFilterer // Log filterer for contract events +} + +// ContractCaller is an auto generated read-only Go binding around an Ethereum contract. +type ContractCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ContractTransactor is an auto generated write-only Go binding around an Ethereum contract. +type ContractTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ContractFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type ContractFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ContractSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type ContractSession struct { + Contract *Contract // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ContractCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type ContractCallerSession struct { + Contract *ContractCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// ContractTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type ContractTransactorSession struct { + Contract *ContractTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ContractRaw is an auto generated low-level Go binding around an Ethereum contract. +type ContractRaw struct { + Contract *Contract // Generic contract binding to access the raw methods on +} + +// ContractCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type ContractCallerRaw struct { + Contract *ContractCaller // Generic read-only contract binding to access the raw methods on +} + +// ContractTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type ContractTransactorRaw struct { + Contract *ContractTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewContract creates a new instance of Contract, bound to a specific deployed contract. +func NewContract(address common.Address, backend bind.ContractBackend) (*Contract, error) { + contract, err := bindContract(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &Contract{ContractCaller: ContractCaller{contract: contract}, ContractTransactor: ContractTransactor{contract: contract}, ContractFilterer: ContractFilterer{contract: contract}}, nil +} + +// NewContractCaller creates a new read-only instance of Contract, bound to a specific deployed contract. +func NewContractCaller(address common.Address, caller bind.ContractCaller) (*ContractCaller, error) { + contract, err := bindContract(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &ContractCaller{contract: contract}, nil +} + +// NewContractTransactor creates a new write-only instance of Contract, bound to a specific deployed contract. +func NewContractTransactor(address common.Address, transactor bind.ContractTransactor) (*ContractTransactor, error) { + contract, err := bindContract(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &ContractTransactor{contract: contract}, nil +} + +// NewContractFilterer creates a new log filterer instance of Contract, bound to a specific deployed contract. +func NewContractFilterer(address common.Address, filterer bind.ContractFilterer) (*ContractFilterer, error) { + contract, err := bindContract(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &ContractFilterer{contract: contract}, nil +} + +// bindContract binds a generic wrapper to an already deployed contract. +func bindContract(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := ContractMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Contract *ContractRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Contract.Contract.ContractCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Contract *ContractRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Contract.Contract.ContractTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Contract *ContractRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Contract.Contract.ContractTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Contract *ContractCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Contract.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Contract *ContractTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Contract.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Contract *ContractTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Contract.Contract.contract.Transact(opts, method, params...) +} + +// Resolve is a free data retrieval call binding the contract method 0x9061b923. +// +// Solidity: function resolve(bytes name, bytes data) view returns(bytes, address) +func (_Contract *ContractCaller) Resolve(opts *bind.CallOpts, name []byte, data []byte) ([]byte, common.Address, error) { + var out []interface{} + err := _Contract.contract.Call(opts, &out, "resolve", name, data) + + if err != nil { + return *new([]byte), *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new([]byte)).(*[]byte) + out1 := *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + + return out0, out1, err + +} + +// Resolve is a free data retrieval call binding the contract method 0x9061b923. +// +// Solidity: function resolve(bytes name, bytes data) view returns(bytes, address) +func (_Contract *ContractSession) Resolve(name []byte, data []byte) ([]byte, common.Address, error) { + return _Contract.Contract.Resolve(&_Contract.CallOpts, name, data) +} + +// Resolve is a free data retrieval call binding the contract method 0x9061b923. +// +// Solidity: function resolve(bytes name, bytes data) view returns(bytes, address) +func (_Contract *ContractCallerSession) Resolve(name []byte, data []byte) ([]byte, common.Address, error) { + return _Contract.Contract.Resolve(&_Contract.CallOpts, name, data) +} + +// Reverse is a free data retrieval call binding the contract method 0x5d78a217. +// +// Solidity: function reverse(bytes lookupAddress, uint256 coinType) view returns(string, address, address) +func (_Contract *ContractCaller) Reverse(opts *bind.CallOpts, lookupAddress []byte, coinType *big.Int) (string, common.Address, common.Address, error) { + var out []interface{} + err := _Contract.contract.Call(opts, &out, "reverse", lookupAddress, coinType) + + if err != nil { + return *new(string), *new(common.Address), *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + out1 := *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + out2 := *abi.ConvertType(out[2], new(common.Address)).(*common.Address) + + return out0, out1, out2, err + +} + +// Reverse is a free data retrieval call binding the contract method 0x5d78a217. +// +// Solidity: function reverse(bytes lookupAddress, uint256 coinType) view returns(string, address, address) +func (_Contract *ContractSession) Reverse(lookupAddress []byte, coinType *big.Int) (string, common.Address, common.Address, error) { + return _Contract.Contract.Reverse(&_Contract.CallOpts, lookupAddress, coinType) +} + +// Reverse is a free data retrieval call binding the contract method 0x5d78a217. +// +// Solidity: function reverse(bytes lookupAddress, uint256 coinType) view returns(string, address, address) +func (_Contract *ContractCallerSession) Reverse(lookupAddress []byte, coinType *big.Int) (string, common.Address, common.Address, error) { + return _Contract.Contract.Reverse(&_Contract.CallOpts, lookupAddress, coinType) +} diff --git a/contracts/universalresolver/generate.go b/contracts/universalresolver/generate.go new file mode 100644 index 0000000..f1e9c51 --- /dev/null +++ b/contracts/universalresolver/generate.go @@ -0,0 +1,3 @@ +package universalresolver + +//go:generate abigen -abi contract.abi -out contract.go -pkg universalresolver -type Contract diff --git a/deed.go b/deed.go index 72b5544..14c7310 100644 --- a/deed.go +++ b/deed.go @@ -18,7 +18,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/deed" + "github.com/wealdtech/go-ens/v4/contracts/deed" ) // Deed is the structure for the deed. diff --git a/dnsencode.go b/dnsencode.go new file mode 100644 index 0000000..101788e --- /dev/null +++ b/dnsencode.go @@ -0,0 +1,57 @@ +// 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 ens + +import ( + "errors" + "fmt" + "strings" +) + +// DNSEncode converts an ENS name into the DNS wire format used by +// EIP-137 / the Universal Resolver: each label is preceded by a +// single byte holding its length, and the encoding is terminated +// with a zero byte representing the root label. The empty string +// encodes to a single zero byte. +// +// Per ENSIP-10 the encoding follows RFC 1035 §3.1 — labels are at +// most 63 bytes (the top two bits of the length octet are reserved +// for compression pointers) — except that ENSIP-10 explicitly removes +// RFC 1035's 255-octet limit on the total encoded name length. +func DNSEncode(name string) ([]byte, error) { + if name == "" { + return []byte{0}, nil + } + // Trim a single trailing dot before normalisation — ENSIP-15 rejects + // empty labels, but DNS-style "foo.eth." input is convenient. Multiple + // trailing dots still produce an empty label and are rejected. + name = strings.TrimSuffix(name, ".") + if name == "" { + return []byte{0}, nil + } + normalised, err := Normalize(name) + if err != nil { + return nil, err + } + parts := strings.Split(normalised, ".") + out := make([]byte, 0, len(normalised)+len(parts)+1) + for i, label := range parts { + if label == "" { + return nil, fmt.Errorf("empty label at position %d in %q", i, name) + } + labelBytes := []byte(label) + if len(labelBytes) > 63 { + return nil, errors.New("label exceeds 63 bytes") + } + out = append(out, byte(len(labelBytes))) + out = append(out, labelBytes...) + } + out = append(out, 0) + return out, nil +} diff --git a/dnsencode_test.go b/dnsencode_test.go new file mode 100644 index 0000000..714df31 --- /dev/null +++ b/dnsencode_test.go @@ -0,0 +1,240 @@ +// 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 ens_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + ens "github.com/wealdtech/go-ens/v4" +) + +// --------------------------------------------------------------------------- +// Happy paths. +// --------------------------------------------------------------------------- + +func TestDNSEncode_Empty(t *testing.T) { + out, err := ens.DNSEncode("") + require.NoError(t, err) + assert.Equal(t, []byte{0}, out) +} + +func TestDNSEncode_SingleLabel(t *testing.T) { + out, err := ens.DNSEncode("eth") + require.NoError(t, err) + assert.Equal(t, []byte{3, 'e', 't', 'h', 0}, out) +} + +func TestDNSEncode_TwoLabels(t *testing.T) { + out, err := ens.DNSEncode("foo.eth") + require.NoError(t, err) + assert.Equal(t, []byte{3, 'f', 'o', 'o', 3, 'e', 't', 'h', 0}, out) +} + +func TestDNSEncode_DeepLabels(t *testing.T) { + out, err := ens.DNSEncode("a.b.c.eth") + require.NoError(t, err) + assert.Equal(t, []byte{1, 'a', 1, 'b', 1, 'c', 3, 'e', 't', 'h', 0}, out) +} + +func TestDNSEncode_SingleCharLabels(t *testing.T) { + out, err := ens.DNSEncode("a.b.c") + require.NoError(t, err) + assert.Equal(t, []byte{1, 'a', 1, 'b', 1, 'c', 0}, out) +} + +func TestDNSEncode_NumericLabel(t *testing.T) { + out, err := ens.DNSEncode("123.eth") + require.NoError(t, err) + assert.Equal(t, []byte{3, '1', '2', '3', 3, 'e', 't', 'h', 0}, out) +} + +func TestDNSEncode_HyphenatedLabel(t *testing.T) { + out, err := ens.DNSEncode("foo-bar.eth") + require.NoError(t, err) + assert.Equal(t, []byte{7, 'f', 'o', 'o', '-', 'b', 'a', 'r', 3, 'e', 't', 'h', 0}, out) +} + +// --------------------------------------------------------------------------- +// Normalization (IDNA MapForLookup should lowercase). +// --------------------------------------------------------------------------- + +func TestDNSEncode_NormalizesToLowercase(t *testing.T) { + out, err := ens.DNSEncode("FOO.ETH") + require.NoError(t, err) + assert.Equal(t, []byte{3, 'f', 'o', 'o', 3, 'e', 't', 'h', 0}, out) +} + +func TestDNSEncode_NormalizesMixedCase(t *testing.T) { + out, err := ens.DNSEncode("Foo.ETH") + require.NoError(t, err) + assert.Equal(t, []byte{3, 'f', 'o', 'o', 3, 'e', 't', 'h', 0}, out) +} + +// --------------------------------------------------------------------------- +// Trailing-dot handling. +// --------------------------------------------------------------------------- + +func TestDNSEncode_SingleTrailingDot(t *testing.T) { + out, err := ens.DNSEncode("foo.eth.") + require.NoError(t, err) + assert.Equal(t, []byte{3, 'f', 'o', 'o', 3, 'e', 't', 'h', 0}, out) +} + +// The doc comment states only ONE trailing dot is trimmed; a second produces +// an empty label and must be rejected. +func TestDNSEncode_DoubleTrailingDot(t *testing.T) { + _, err := ens.DNSEncode("foo.eth..") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty label") +} + +// --------------------------------------------------------------------------- +// Empty-label rejection (leading dot, only-dot, double-dot in middle). +// --------------------------------------------------------------------------- + +func TestDNSEncode_LeadingDot(t *testing.T) { + _, err := ens.DNSEncode(".eth") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty label") +} + +// A single dot is the DNS root domain — it encodes to a single null byte, +// matching the empty-string case. The trailing-dot trim absorbs the dot +// before normalisation runs, so this is allowed (consistent with +// TestDNSEncode_Empty). +func TestDNSEncode_OnlyDot(t *testing.T) { + out, err := ens.DNSEncode(".") + require.NoError(t, err) + require.Equal(t, []byte{0}, out) +} + +func TestDNSEncode_DoubleDotInMiddle(t *testing.T) { + _, err := ens.DNSEncode("foo..eth") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty label") +} + +// --------------------------------------------------------------------------- +// Label-length boundaries. +// +// RFC 1035 §3.1 caps a DNS label at 63 bytes. The top 2 bits of the length +// byte are reserved for compression-pointer flags, so a 64+ byte label cannot +// be encoded validly. +// +// The current implementation uses a 63-byte cap. +// --------------------------------------------------------------------------- + +func TestDNSEncode_Label63Bytes(t *testing.T) { + label := strings.Repeat("a", 63) + out, err := ens.DNSEncode(label + ".eth") + require.NoError(t, err) + assert.Equal(t, byte(63), out[0]) + assert.Equal(t, label, string(out[1:64])) +} + +func TestDNSEncode_Label64Bytes(t *testing.T) { + label := strings.Repeat("a", 64) + _, err := ens.DNSEncode(label + ".eth") + require.Error(t, err, "labels above 63 bytes are invalid per RFC 1035 §3.1") +} + +func TestDNSEncode_Label255Bytes(t *testing.T) { + label := strings.Repeat("a", 255) + _, err := ens.DNSEncode(label + ".eth") + require.Error(t, err, "labels above 63 bytes are invalid per RFC 1035 §3.1") +} + +// 256-byte label is rejected by the implementation's `> 63` check. +func TestDNSEncode_Label256Bytes(t *testing.T) { + label := strings.Repeat("a", 256) + _, err := ens.DNSEncode(label + ".eth") + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// Total-name-length boundaries. +// +// ENSIP-10 explicitly removes RFC 1035's 255-octet cap on the total encoded +// name, so DNSEncode must accept arbitrarily long encodings as long as each +// label individually conforms. +// --------------------------------------------------------------------------- + +// 255 octets total: 63 + 63 + 63 + 61 data bytes; +4 length bytes +1 null. +func TestDNSEncode_TotalLength255(t *testing.T) { + parts := []string{ + strings.Repeat("a", 63), + strings.Repeat("b", 63), + strings.Repeat("c", 63), + strings.Repeat("d", 61), + } + out, err := ens.DNSEncode(strings.Join(parts, ".")) + require.NoError(t, err) + assert.Len(t, out, 255) +} + +// 256 octets — past the RFC 1035 total-length cap, but ENSIP-10 removes that +// cap, so this must still succeed. +func TestDNSEncode_TotalLength256(t *testing.T) { + parts := []string{ + strings.Repeat("a", 63), + strings.Repeat("b", 63), + strings.Repeat("c", 63), + strings.Repeat("d", 62), + } + out, err := ens.DNSEncode(strings.Join(parts, ".")) + require.NoError(t, err, "ENSIP-10 explicitly removes the RFC 1035 255-octet cap") + assert.Len(t, out, 256) +} + +// Far beyond RFC 1035's cap — ENSIP-10 still requires this to succeed. +func TestDNSEncode_TotalLengthFarAbove255(t *testing.T) { + // 10 labels of 63 bytes each = 630 data + 10 length + 1 null = 641 octets. + parts := make([]string, 10) + for i := range parts { + parts[i] = strings.Repeat("a", 63) + } + out, err := ens.DNSEncode(strings.Join(parts, ".")) + require.NoError(t, err) + assert.Len(t, out, 641) +} + +// --------------------------------------------------------------------------- +// Non-ASCII / IDN labels. +// +// Normalize() uses idna.ToUnicode + MapForLookup, so the returned form is +// Unicode (NOT Punycode). DNSEncode then takes UTF-8 bytes verbatim. +// "münchen" UTF-8 = m(0x6D) ü(0xC3 0xBC) n(0x6E) c(0x63) h(0x68) e(0x65) n(0x6E) +// = 8 bytes. +// --------------------------------------------------------------------------- + +func TestDNSEncode_NonASCII(t *testing.T) { + out, err := ens.DNSEncode("münchen.eth") + require.NoError(t, err) + expected := []byte{ + 8, 'm', 0xC3, 0xBC, 'n', 'c', 'h', 'e', 'n', + 3, 'e', 't', 'h', + 0, + } + assert.Equal(t, expected, out) +} + +// IDNA MapForLookup should case-fold uppercase non-ASCII to lowercase. +func TestDNSEncode_NonASCIIUppercase(t *testing.T) { + out, err := ens.DNSEncode("MÜNCHEN.eth") + require.NoError(t, err) + expected := []byte{ + 8, 'm', 0xC3, 0xBC, 'n', 'c', 'h', 'e', 'n', + 3, 'e', 't', 'h', + 0, + } + assert.Equal(t, expected, out) +} diff --git a/dnsregistrar.go b/dnsregistrar.go index 1dfa50e..a0d36e8 100644 --- a/dnsregistrar.go +++ b/dnsregistrar.go @@ -19,7 +19,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - "github.com/wealdtech/go-ens/v3/contracts/dnsregistrar" + "github.com/wealdtech/go-ens/v4/contracts/dnsregistrar" ) // DNSRegistrar is the structure for the registrar. diff --git a/dnsresolver.go b/dnsresolver.go index d6b3ba4..78201be 100644 --- a/dnsresolver.go +++ b/dnsresolver.go @@ -21,7 +21,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/dnsresolver" + "github.com/wealdtech/go-ens/v4/contracts/dnsresolver" "golang.org/x/crypto/sha3" ) diff --git a/dnssecoracle.go b/dnssecoracle.go index be9daf8..4d5e308 100644 --- a/dnssecoracle.go +++ b/dnssecoracle.go @@ -17,7 +17,7 @@ package ens import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - "github.com/wealdtech/go-ens/v3/contracts/dnssecoracle" + "github.com/wealdtech/go-ens/v4/contracts/dnssecoracle" ) // DNSSECOracle is the structure for the DNSSEC oracle. diff --git a/ethcontroller.go b/ethcontroller.go index 2b30460..ebd908f 100644 --- a/ethcontroller.go +++ b/ethcontroller.go @@ -23,7 +23,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/ethcontroller" + "github.com/wealdtech/go-ens/v4/contracts/ethcontroller" ) // ETHController is the structure for the .eth controller contract. diff --git a/go.mod b/go.mod index edd960b..0571589 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ -module github.com/wealdtech/go-ens/v3 +module github.com/wealdtech/go-ens/v4 -go 1.22.0 +go 1.22.4 toolchain go1.23.2 require ( + github.com/adraffy/go-ens-normalize v0.1.1 github.com/ethereum/go-ethereum v1.14.12 github.com/ipfs/go-cid v0.4.1 github.com/multiformats/go-multibase v0.2.0 diff --git a/go.sum b/go.sum index 88e70b3..dff705c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= +github.com/adraffy/go-ens-normalize v0.1.1 h1:N//kZB/aSdBLAbUFX52iC5d7EHVgkxmLkLQ3nQnvkwE= +github.com/adraffy/go-ens-normalize v0.1.1/go.mod h1:2wzkGeMLp+VO8lqbu4MYrFeQEVWSV6CGN1Vznrt+Gt0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= diff --git a/mainnet_client_ext_test.go b/mainnet_client_ext_test.go new file mode 100644 index 0000000..d4be537 --- /dev/null +++ b/mainnet_client_ext_test.go @@ -0,0 +1,39 @@ +// 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 ens_test + +import ( + "os" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// mainnetClient connects to a public Ethereum mainnet RPC. The endpoint can +// be overridden with GO_ENS_TEST_RPC; without an override the test falls +// back to a public endpoint so that `go test` works out of the box. +// +// Live-RPC tests skip themselves under `go test -short`, so CI runs that +// can't reach the network (or that are rate-limited) keep working. +func mainnetClient(t *testing.T) *ethclient.Client { + t.Helper() + if testing.Short() { + t.Skip("skipping live-RPC test under -short") + } + url := os.Getenv("GO_ENS_TEST_RPC") + if url == "" { + // Public mainnet RPC. Surfaces revert data via the JSON-RPC `data` + // field, which is required for CCIP-Read. + url = "https://ethereum.publicnode.com" + } + client, err := ethclient.Dial(url) + require.NoError(t, err, "failed to dial %s", url) + return client +} diff --git a/mainnet_client_test.go b/mainnet_client_test.go new file mode 100644 index 0000000..1954e43 --- /dev/null +++ b/mainnet_client_test.go @@ -0,0 +1,39 @@ +// 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 ens + +import ( + "os" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// mainnetClient connects to a public Ethereum mainnet RPC. The endpoint can +// be overridden with GO_ENS_TEST_RPC; without an override the test falls +// back to a public endpoint so that `go test` works out of the box. +// +// Live-RPC tests skip themselves under `go test -short`, so CI runs that +// can't reach the network (or that are rate-limited) keep working. +func mainnetClient(t *testing.T) *ethclient.Client { + t.Helper() + if testing.Short() { + t.Skip("skipping live-RPC test under -short") + } + url := os.Getenv("GO_ENS_TEST_RPC") + if url == "" { + // Public mainnet RPC. Surfaces revert data via the JSON-RPC `data` + // field, which is required for CCIP-Read. + url = "https://ethereum.publicnode.com" + } + client, err := ethclient.Dial(url) + require.NoError(t, err, "failed to dial %s", url) + return client +} diff --git a/misc.go b/misc.go index 7652cd0..53d1ff2 100644 --- a/misc.go +++ b/misc.go @@ -3,6 +3,8 @@ package ens import ( "fmt" "strings" + + "github.com/adraffy/go-ens-normalize/ensip15" ) // DomainLevel calculates the level of the domain presented. @@ -13,32 +15,33 @@ func DomainLevel(name string) int { return len(strings.Split(name, ".")) - 1 } -// NormaliseDomain turns ENS domain in to normal form. +// NormaliseDomain turns an ENS domain into normal form per ENSIP-15. +// +// A leading "*." wildcard prefix is preserved (stripped before normalisation +// and re-attached afterwards); the rest of the name is normalised by the +// canonical ENSIP-15 implementation. Empty labels, disallowed codepoints, +// and other ENSIP-15 violations return an error. func NormaliseDomain(domain string) (string, error) { wildcard := false if strings.HasPrefix(domain, "*.") { wildcard = true domain = domain[2:] } - output, err := p.ToUnicode(domain) + output, err := ensip15.Shared().Normalize(domain) if err != nil { return "", err } - - // ToUnicode() removes leading periods. Replace them. - if strings.HasPrefix(domain, ".") && !strings.HasPrefix(output, ".") { - output = "." + output - } - - // If we removed a wildcard then add it back. if wildcard { output = "*." + output } return output, nil } -// NormaliseDomainStrict turns ENS domain in to normal form, using strict DNS -// rules (e.g. no underscores). +// NormaliseDomainStrict applies strict DNS rules (e.g. no underscores) via +// golang.org/x/net/idna with StrictDomainName=true. This is NOT ENSIP-15 +// normalisation — for that, use NormaliseDomain. NormaliseDomainStrict is +// kept for callers that need DNS-strict validation, e.g. before publishing +// a name to a DNS-backed registrar. func NormaliseDomainStrict(domain string) (string, error) { wildcard := false if strings.HasPrefix(domain, "*.") { diff --git a/misc_test.go b/misc_test.go index 776c2a2..3e8fd0d 100644 --- a/misc_test.go +++ b/misc_test.go @@ -10,43 +10,40 @@ import ( ) func TestNormaliseDomain(t *testing.T) { + // ENSIP-15 rejects empty labels, so inputs that resolve to one (".", ".eth", + // "foo.eth.", ".wealdtech.eth", etc.) now error rather than silently + // round-trip the way the old IDNA-only normaliser allowed. tests := []struct { - input string - output string - err error + input string + output string + wantErr bool }{ - {"", "", nil}, - {".", ".", nil}, - {"eth", "eth", nil}, - {"ETH", "eth", nil}, - {".eth", ".eth", nil}, - {".eth.", ".eth.", nil}, - {"wealdtech.eth", "wealdtech.eth", nil}, - {".wealdtech.eth", ".wealdtech.eth", nil}, - {"subdomain.wealdtech.eth", "subdomain.wealdtech.eth", nil}, - {"*.wealdtech.eth", "*.wealdtech.eth", nil}, - {"omg.thetoken.eth", "omg.thetoken.eth", nil}, - {"_underscore.thetoken.eth", "_underscore.thetoken.eth", nil}, - {"點看.eth", "點看.eth", nil}, + {input: "", output: ""}, + {input: ".", wantErr: true}, + {input: "eth", output: "eth"}, + {input: "ETH", output: "eth"}, + {input: ".eth", wantErr: true}, + {input: ".eth.", wantErr: true}, + {input: "wealdtech.eth", output: "wealdtech.eth"}, + {input: ".wealdtech.eth", wantErr: true}, + {input: "subdomain.wealdtech.eth", output: "subdomain.wealdtech.eth"}, + {input: "*.wealdtech.eth", output: "*.wealdtech.eth"}, + {input: "omg.thetoken.eth", output: "omg.thetoken.eth"}, + // Underscore-leading labels are still valid under ENSIP-15. + {input: "_underscore.thetoken.eth", output: "_underscore.thetoken.eth"}, + {input: "點看.eth", output: "點看.eth"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result, err := NormaliseDomain(tt.input) - if tt.err != nil { - if err == nil { - t.Fatalf("missing expected error") - } - if tt.err.Error() != err.Error() { - t.Errorf("unexpected error value %v", err) - } - } else { - if err != nil { - t.Fatalf("unexpected error %v", err) - } - if tt.output != result { - t.Errorf("%v => %v (expected %v)", tt.input, result, tt.output) - } + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.output != result { + t.Errorf("%v => %v (expected %v)", tt.input, result, tt.output) } }) } @@ -96,6 +93,11 @@ func TestNormaliseDomainStrict(t *testing.T) { } func TestTld(t *testing.T) { + // Under ENSIP-15, inputs with leading dots (".eth", ".wealdtech.eth", ".") + // produce an empty label and NormaliseDomain errors. Tld currently returns + // "" when normalisation fails (NormaliseDomain returns "" on error). Pre- + // ENSIP-15 behaviour returned "eth" for ".eth" and ".wealdtech.eth"; that + // has been corrected. tests := []struct { input string output string @@ -104,9 +106,9 @@ func TestTld(t *testing.T) { {".", ""}, {"eth", "eth"}, {"ETH", "eth"}, - {".eth", "eth"}, + {".eth", ""}, {"wealdtech.eth", "eth"}, - {".wealdtech.eth", "eth"}, + {".wealdtech.eth", ""}, {"subdomain.wealdtech.eth", "eth"}, } @@ -129,21 +131,25 @@ func TestDomainPart(t *testing.T) { {"", 2, "", true}, {"", -1, "", false}, {"", -2, "", true}, - {".", 1, "", false}, - {".", 2, "", false}, + // ENSIP-15: a bare "." is an empty label and now errors. + {".", 1, "", true}, + {".", 2, "", true}, {".", 3, "", true}, - {".", -1, "", false}, - {".", -2, "", false}, + {".", -1, "", true}, + {".", -2, "", true}, {".", -3, "", true}, {"ETH", 1, "eth", false}, {"ETH", 2, "", true}, {"ETH", -1, "eth", false}, {"ETH", -2, "", true}, - {".ETH", 1, "", false}, - {".ETH", 2, "eth", false}, + // ENSIP-15: a leading dot produces an empty label and now errors for + // every part index — including the negative indices that previously + // happened to land on a non-empty label. + {".ETH", 1, "", true}, + {".ETH", 2, "", true}, {".ETH", 3, "", true}, - {".ETH", -1, "eth", false}, - {".ETH", -2, "", false}, + {".ETH", -1, "", true}, + {".ETH", -2, "", true}, {".ETH", -3, "", true}, {"wealdtech.eth", 1, "wealdtech", false}, {"wealdtech.eth", 2, "eth", false}, @@ -151,13 +157,13 @@ func TestDomainPart(t *testing.T) { {"wealdtech.eth", -1, "eth", false}, {"wealdtech.eth", -2, "wealdtech", false}, {"wealdtech.eth", -3, "", true}, - {".wealdtech.eth", 1, "", false}, - {".wealdtech.eth", 2, "wealdtech", false}, - {".wealdtech.eth", 3, "eth", false}, + {".wealdtech.eth", 1, "", true}, + {".wealdtech.eth", 2, "", true}, + {".wealdtech.eth", 3, "", true}, {".wealdtech.eth", 4, "", true}, - {".wealdtech.eth", -1, "eth", false}, - {".wealdtech.eth", -2, "wealdtech", false}, - {".wealdtech.eth", -3, "", false}, + {".wealdtech.eth", -1, "", true}, + {".wealdtech.eth", -2, "", true}, + {".wealdtech.eth", -3, "", true}, {".wealdtech.eth", -4, "", true}, {"subdomain.wealdtech.eth", 1, "subdomain", false}, {"subdomain.wealdtech.eth", 2, "wealdtech", false}, diff --git a/name.go b/name.go index d112b03..d1eb145 100644 --- a/name.go +++ b/name.go @@ -15,6 +15,7 @@ package ens import ( + "context" "crypto/rand" "errors" "fmt" @@ -295,12 +296,24 @@ func (n *Name) SetResolverAddress(address common.Address, opts *bind.TransactOpt // Address fetches the address of the name for a given coin type. // Coin types are defined at https://github.com/satoshilabs/slips/blob/master/slip-0044.md -func (n *Name) Address(coinType uint64) ([]byte, error) { - resolver, err := NewResolver(n.backend, n.Name) +// +// Resolution is delegated to the UniversalResolver, so it follows ENSIP-10 +// wildcard resolution and ERC-3668 CCIP-Read and returns the same answer as +// ens.Resolve for coin type 60. ctx is honoured for cancellation and deadline +// propagation. +func (n *Name) Address(ctx context.Context, coinType uint64) ([]byte, error) { + ur, err := NewUniversalResolver(ctx, n.backend) if err != nil { return nil, err } - return resolver.MultiAddress(coinType) + if coinType == CoinTypeETH { + addr, err := ur.ResolveAddress(ctx, n.Name) + if err != nil { + return nil, err + } + return addr.Bytes(), nil + } + return ur.MultiAddress(ctx, n.Name, coinType) } // SetAddress sets the address of the name for a given coin type. diff --git a/name_test.go b/name_test.go index a74973c..efab529 100644 --- a/name_test.go +++ b/name_test.go @@ -43,7 +43,7 @@ func TestName(t *testing.T) { dsExpiry := time.Unix(1904119920, 0) dsRegistrationInterval := 60 * time.Second - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "resolver.eth") require.Nil(t, err, "Failed to create name") @@ -70,7 +70,7 @@ func TestName(t *testing.T) { } func TestNameExpiry(t *testing.T) { - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) domain := unregisteredDomain(client) name, err := NewName(client, domain) require.Nil(t, err, "Failed to create name") @@ -83,7 +83,7 @@ func TestNameReRegistration(t *testing.T) { if !hasPrivateKey(registrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "resolver.eth") require.Nil(t, err, "Failed to create name") @@ -95,13 +95,13 @@ func TestNameReRegistration(t *testing.T) { } func TestInvalidName(t *testing.T) { - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) _, err := NewName(client, "ab.eth") require.Equal(t, err.Error(), "name is not valid according to the rules of the registrar (too short, invalid characters, etc.)") } func TestNameRegistration(t *testing.T) { - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) registrant := common.HexToAddress("388Ea662EF2c223eC0B047D41Bf3c0f362142ad5") if !hasPrivateKey(registrant) { t.Skip() @@ -140,7 +140,7 @@ func TestNameRegistration(t *testing.T) { } func TestNameRegistrationStageTwoNoStageOne(t *testing.T) { - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) registrant := common.HexToAddress("388Ea662EF2c223eC0B047D41Bf3c0f362142ad5") if !hasPrivateKey(registrant) { t.Skip() @@ -159,7 +159,7 @@ func TestNameRegistrationStageTwoNoStageOne(t *testing.T) { } func TestNameRegistrationNoValue(t *testing.T) { - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) registrant := common.HexToAddress("388Ea662EF2c223eC0B047D41Bf3c0f362142ad5") if !hasPrivateKey(registrant) { t.Skip() @@ -192,7 +192,7 @@ func TestNameRegistrationNoValue(t *testing.T) { } func TestNameRegistrationNoInterval(t *testing.T) { - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) registrant := common.HexToAddress("388Ea662EF2c223eC0B047D41Bf3c0f362142ad5") if !hasPrivateKey(registrant) { t.Skip() @@ -223,7 +223,7 @@ func TestNameExtension(t *testing.T) { if !hasPrivateKey(registrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -247,7 +247,7 @@ func TestNameExtensionLowValue(t *testing.T) { if !hasPrivateKey(registrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -262,7 +262,7 @@ func TestNameExtensionNotRegistered(t *testing.T) { if !hasPrivateKey(registrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) domain := unregisteredDomain(client) name, err := NewName(client, domain) require.Nil(t, err, "Failed to create name") @@ -278,7 +278,7 @@ func TestNameSubdomainCreate(t *testing.T) { if !hasPrivateKey(dsRegistrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -309,7 +309,7 @@ func TestNameSubdomainCreateAlreadyExists(t *testing.T) { if !hasPrivateKey(dsRegistrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -330,7 +330,7 @@ func TestSetController(t *testing.T) { t.Skip() } dsController := common.HexToAddress("E195c59BCF26fD36c82d1C720860127A5c1c4040") - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -374,7 +374,7 @@ func TestSetControllerUnauthorised(t *testing.T) { if !hasPrivateKey(dsThief) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -398,7 +398,7 @@ func TestReclaim(t *testing.T) { t.Skip() } dsController := common.HexToAddress("E195c59BCF26fD36c82d1C720860127A5c1c4040") - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -439,7 +439,7 @@ func TestReclaimUnauthorised(t *testing.T) { if !hasPrivateKey(dsThief) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -466,7 +466,7 @@ func TestTransfer(t *testing.T) { if !hasPrivateKey(dsNewRegistrant) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") @@ -507,7 +507,7 @@ func TestTransferUnauthorised(t *testing.T) { if !hasPrivateKey(dsThief) { t.Skip() } - client, _ := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") + client := mainnetClient(t) name, err := NewName(client, "foobar5.eth") require.Nil(t, err, "Failed to create name") diff --git a/namehash.go b/namehash.go index dd5e8ff..93cc987 100644 --- a/namehash.go +++ b/namehash.go @@ -17,28 +17,28 @@ package ens import ( "strings" + "github.com/adraffy/go-ens-normalize/ensip15" "github.com/pkg/errors" "golang.org/x/net/idna" "golang.org/x/crypto/sha3" ) -var ( - p = idna.New(idna.MapForLookup(), idna.ValidateLabels(false), idna.CheckHyphens(false), idna.StrictDomainName(false), idna.Transitional(false)) - pStrict = idna.New(idna.MapForLookup(), idna.ValidateLabels(false), idna.CheckHyphens(false), idna.StrictDomainName(true), idna.Transitional(false)) -) +// pStrict is the legacy IDNA "strict" profile, used only by +// NormaliseDomainStrict for DNS-strict checks (no underscores, etc.). It is +// NOT an ENSIP-15 normalizer — see Normalize / NormaliseDomain for that. +var pStrict = idna.New(idna.MapForLookup(), idna.ValidateLabels(false), idna.CheckHyphens(false), idna.StrictDomainName(true), idna.Transitional(false)) -// Normalize normalizes a name according to the ENS rules. +// Normalize normalizes a name according to ENSIP-15. Empty labels, disallowed +// codepoints, mixed-script confusables, and other ENSIP-15 violations return +// an error. The implementation delegates to github.com/adraffy/go-ens-normalize, +// the canonical Go port (Unicode 17.0.0, passes the full ENSIP-15 validation +// suite). func Normalize(input string) (string, error) { - output, err := p.ToUnicode(input) + output, err := ensip15.Shared().Normalize(input) if err != nil { - return "", errors.Wrap(err, "failed to convert to standard unicode") - } - // If the name started with a period then ToUnicode() removes it, but we want to keep it. - if strings.HasPrefix(input, ".") && !strings.HasPrefix(output, ".") { - output = "." + output + return "", errors.Wrap(err, "failed to ensip-15 normalize") } - return output, nil } diff --git a/namehash_test.go b/namehash_test.go index aaa883d..a27876b 100644 --- a/namehash_test.go +++ b/namehash_test.go @@ -17,73 +17,83 @@ package ens import ( "encoding/hex" "testing" + + "github.com/stretchr/testify/require" ) func TestNameHash(t *testing.T) { + // Pre-ENSIP-15 fixtures that produced valid hashes for inputs containing + // empty labels (".eth", "foo..eth") are now expected to error — ENSIP-15 + // rejects empty labels rather than silently round-tripping them. tests := []struct { - input string - output string - err error + input string + output string + wantErr bool }{ - {"", "0000000000000000000000000000000000000000000000000000000000000000", nil}, - {"eth", "93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae", nil}, - {"Eth", "93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae", nil}, - {".eth", "8cc9f31a5e7af6381efc751d98d289e3f3589f1b6f19b9b989ace1788b939cf7", nil}, - {"resolver.eth", "fdd5d5de6dd63db72bbc2d487944ba13bf775b50a80805fe6fcaba9b0fba88f5", nil}, - {"foo.eth", "de9b09fd7c5f901e23a3f19fecc54828e9c848539801e86591bd9801b019f84f", nil}, - {"Foo.eth", "de9b09fd7c5f901e23a3f19fecc54828e9c848539801e86591bd9801b019f84f", nil}, - {"foo..eth", "4143a5b2f547838d3b49982e3f2ec6a26415274e5b9c3ffeb21971bbfdfaa052", nil}, - {"bar.foo.eth", "275ae88e7263cdce5ab6cf296cdd6253f5e385353fe39cfff2dd4a2b14551cf3", nil}, - {"Bar.foo.eth", "275ae88e7263cdce5ab6cf296cdd6253f5e385353fe39cfff2dd4a2b14551cf3", nil}, - {"addr.reverse", "91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2", nil}, - {"🚣‍♂️.eth", "37f681401d88093779de0c910da1f9437759c2181f61b5dbc9e3ef0d9f192513", nil}, - {"vitalik.eth", "ee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835", nil}, - {"nIcK.eTh", "05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00", nil}, - {"brantly.cash", "3cb8d24c572869f7ec5ec5e882243b4790de146ac11267f58fd856d290b593db", nil}, - {"🏴‍☠.art", "4696ec532b8c8bb20dcfa6ae6da710422611ea03fc77aebc0284d0f12c93f768", nil}, - {"öbb.eth", "2774094517aaa884fc7183cf681150529b9acc7c9e7b71cf3a470200135a20b4", nil}, - {"Öbb.eth", "2774094517aaa884fc7183cf681150529b9acc7c9e7b71cf3a470200135a20b4", nil}, - {"💷pound.eth", "2df37f43de8b5ab870370770e78e4503ea1199e46c16db7a4a9320732dbb42bc", nil}, - {"firstwrappedname.eth", "c44eec7fb870ae46d4ef4392d33fbbbdc164e7817a86289a1fe30e5f4d98ae85", nil}, - {"A.™️.Ю", "f48b2941db0e23087a8922f81c0b7dd1b83fa06657080fd28986f9a19752a093", nil}, - {"Ⅷ.eth", "a468899f828f5d80c85c0e538018ba6516713a06aa6d92e8482ee91886630ae9", nil}, - {"⨌.eth", "f0b6090714f4ab523b1c574077018fe49dc934f9334d092e412b3802898b2cee", nil}, - {"-test.test-.t-e--s---t", "0d26fe02b1eb3fdcd7db77a432c60b20d2d8c4ef9012ef06ad3ae7bf12adb225", nil}, - {"te--st.eth", "bea7eca33545aacbf3d6f1512c119aa7f04506d61354001e7af31ba73374b2c3", nil}, - {"__ab.eth", "b3edf1f0b8dc6168b65d034c76b5670813514ac8867115e63b51eb496f3791a0", nil}, - {"a_b.eth", "b99946eaeb8a557bc6329faf95d1b885ba608393109cb9609440acc8e6761c4f", nil}, - {"ß.eth", "86262f2349fb651f652e87141e6db5856cd6e40f92d0b9dc486aaf8f9c509cff", nil}, - {"💩💩💩.eth", "a74feb0e5fa5606d3e650275e3bb3873b006a10d558389d3ce2abbe681fcfc8e", nil}, - {"آٍَ.ऩँं", "a1e3d72c39aec7ebcf62906994da7adc76b8a3ffa27e52b838abb24569610146", nil}, - {"פעילותהבינאום.eth", "7900040d82be5a569289b0f7b30266d796e1ded13293557ed061b39367357a62", nil}, - {"ஶ்ரீ.ஸ்ரீ", "ef5f28b8adff4b9b86b67c40792ef314ecd92a2123a72185b1411835cce5c110", nil}, - {"🇸🇦سلمان.eth", "3484f75c4dbf69503310126dbfc02eec224eab46851de2288092d58785670808", nil}, - {"0a〇.黑a8", "d7bc21f0cb86cafe27ebafe8e788b1c63b736467c65611110575f385a4d01e1f", nil}, - {"apple.дррӏе.аррӏе.aррӏе", "a5428a7fc57a099d7926ac7fa30169933df167acf3d8fba6f5830e6cf7442b31", nil}, - {"۰۱۲۳۷۸۹.eth", "430bc5e65fd3122d45c00dbce4e8e1b51be675794e548f92558dc4cecafd6418", nil}, - {"𓀀𓀁𓀂.eth", "36f75325f4013011b96014606224f03c77366d89caf480a1060d7453edaf5c98", nil}, - {"𓆏➡🐸️.eth", "f2663423c7aadf794d6f84addcfee1f877458d86c20740954482094a20985228", nil}, - {"ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ.eth", "254a068876bdac64ad35425816bf444fbe9a26ec19287f47e663f928727ff69c", nil}, - {"trish🫧.eth", "9a6aa3aaa97ed3b694265d4b0e776b2e8fad34b8869199b1a062e3cc0ee0d41d", nil}, + {input: "", output: "0000000000000000000000000000000000000000000000000000000000000000"}, + {input: "eth", output: "93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae"}, + {input: "Eth", output: "93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae"}, + {input: ".eth", wantErr: true}, + {input: "resolver.eth", output: "fdd5d5de6dd63db72bbc2d487944ba13bf775b50a80805fe6fcaba9b0fba88f5"}, + {input: "foo.eth", output: "de9b09fd7c5f901e23a3f19fecc54828e9c848539801e86591bd9801b019f84f"}, + {input: "Foo.eth", output: "de9b09fd7c5f901e23a3f19fecc54828e9c848539801e86591bd9801b019f84f"}, + {input: "foo..eth", wantErr: true}, + {input: "bar.foo.eth", output: "275ae88e7263cdce5ab6cf296cdd6253f5e385353fe39cfff2dd4a2b14551cf3"}, + {input: "Bar.foo.eth", output: "275ae88e7263cdce5ab6cf296cdd6253f5e385353fe39cfff2dd4a2b14551cf3"}, + {input: "addr.reverse", output: "91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2"}, + {input: "🚣‍♂️.eth", output: "37f681401d88093779de0c910da1f9437759c2181f61b5dbc9e3ef0d9f192513"}, + {input: "vitalik.eth", output: "ee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835"}, + {input: "nIcK.eTh", output: "05a67c0ee82964c4f7394cdd47fee7f4d9503a23c09c38341779ea012afe6e00"}, + {input: "brantly.cash", output: "3cb8d24c572869f7ec5ec5e882243b4790de146ac11267f58fd856d290b593db"}, + {input: "🏴‍☠.art", output: "4696ec532b8c8bb20dcfa6ae6da710422611ea03fc77aebc0284d0f12c93f768"}, + {input: "öbb.eth", output: "2774094517aaa884fc7183cf681150529b9acc7c9e7b71cf3a470200135a20b4"}, + {input: "Öbb.eth", output: "2774094517aaa884fc7183cf681150529b9acc7c9e7b71cf3a470200135a20b4"}, + {input: "💷pound.eth", output: "2df37f43de8b5ab870370770e78e4503ea1199e46c16db7a4a9320732dbb42bc"}, + {input: "firstwrappedname.eth", output: "c44eec7fb870ae46d4ef4392d33fbbbdc164e7817a86289a1fe30e5f4d98ae85"}, + {input: "A.™️.Ю", output: "f48b2941db0e23087a8922f81c0b7dd1b83fa06657080fd28986f9a19752a093"}, + {input: "Ⅷ.eth", output: "a468899f828f5d80c85c0e538018ba6516713a06aa6d92e8482ee91886630ae9"}, + {input: "⨌.eth", output: "f0b6090714f4ab523b1c574077018fe49dc934f9334d092e412b3802898b2cee"}, + // `-test`, `test-`, and `t-e--s---t` are all valid labels — only the + // `XX--` extension prefix at positions 3-4 is invalid (see "te--st.eth" + // below). + {input: "-test.test-.t-e--s---t", output: "0d26fe02b1eb3fdcd7db77a432c60b20d2d8c4ef9012ef06ad3ae7bf12adb225"}, + // ENSIP-15 forbids the `XX--` label extension (RFC 5891-style guard + // against ambiguity with Punycode). + {input: "te--st.eth", wantErr: true}, + {input: "__ab.eth", output: "b3edf1f0b8dc6168b65d034c76b5670813514ac8867115e63b51eb496f3791a0"}, + // ENSIP-15 allows underscores only at the start of a label. + {input: "a_b.eth", wantErr: true}, + {input: "ß.eth", output: "86262f2349fb651f652e87141e6db5856cd6e40f92d0b9dc486aaf8f9c509cff"}, + {input: "💩💩💩.eth", output: "a74feb0e5fa5606d3e650275e3bb3873b006a10d558389d3ce2abbe681fcfc8e"}, + {input: "آٍَ.ऩँं", output: "a1e3d72c39aec7ebcf62906994da7adc76b8a3ffa27e52b838abb24569610146"}, + {input: "פעילותהבינאום.eth", output: "7900040d82be5a569289b0f7b30266d796e1ded13293557ed061b39367357a62"}, + {input: "ஶ்ரீ.ஸ்ரீ", output: "ef5f28b8adff4b9b86b67c40792ef314ecd92a2123a72185b1411835cce5c110"}, + {input: "🇸🇦سلمان.eth", output: "3484f75c4dbf69503310126dbfc02eec224eab46851de2288092d58785670808"}, + {input: "0a〇.黑a8", output: "d7bc21f0cb86cafe27ebafe8e788b1c63b736467c65611110575f385a4d01e1f"}, + // ENSIP-15 explicitly rejects whole-script confusables; this is the + // canonical "Cyrillic-mimics-Latin" example. + {input: "apple.дррӏе.аррӏе.aррӏе", wantErr: true}, + // Hash differs from the pre-ENSIP-15 value because the old IDNA profile + // mapped Arabic-Indic digits to ASCII digits; ENSIP-15 preserves them. + {input: "۰۱۲۳۷۸۹.eth", output: "ff4b07401acb2d0f048c20f84a6ab57f6d66a0a16d81d13bbba87e8cfacd6594"}, + {input: "𓀀𓀁𓀂.eth", output: "36f75325f4013011b96014606224f03c77366d89caf480a1060d7453edaf5c98"}, + {input: "𓆏➡🐸️.eth", output: "f2663423c7aadf794d6f84addcfee1f877458d86c20740954482094a20985228"}, + {input: "ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ.eth", output: "254a068876bdac64ad35425816bf444fbe9a26ec19287f47e663f928727ff69c"}, + {input: "trish🫧.eth", output: "9a6aa3aaa97ed3b694265d4b0e776b2e8fad34b8869199b1a062e3cc0ee0d41d"}, } for _, tt := range tests { - result, err := NameHash(tt.input) - if tt.err == nil { - if err != nil { - t.Fatalf("unexpected error %v", err) + t.Run(tt.input, func(t *testing.T) { + result, err := NameHash(tt.input) + if tt.wantErr { + require.Error(t, err) + return } - if tt.output != hex.EncodeToString(result[:]) { - t.Errorf("Failure: %v => %v (expected %v)\n", tt.input, hex.EncodeToString(result[:]), tt.output) + require.NoError(t, err) + if got := hex.EncodeToString(result[:]); got != tt.output { + t.Errorf("%v => %v (expected %v)", tt.input, got, tt.output) } - } else { - if err == nil { - t.Fatalf("missing expected error") - } - if tt.err.Error() != err.Error() { - t.Errorf("unexpected error value %v", err) - } - } + }) } } diff --git a/normalize_ensip15_test.go b/normalize_ensip15_test.go new file mode 100644 index 0000000..6aeee4e --- /dev/null +++ b/normalize_ensip15_test.go @@ -0,0 +1,92 @@ +// 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 ens + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// These tests lock in the ENSIP-15 behaviours that distinguish the +// adraffy/go-ens-normalize implementation from the pre-ENSIP-15 IDNA-only +// normaliser previously used in this package. They are intentionally narrow: +// the upstream library has its own 5000+ vector suite against the canonical +// ENSIP-15 validation tests — replicating those here would duplicate work +// without adding value. These tests instead exercise the wiring (our +// public Normalize function, error surface, and the categories of input +// the report's R15 recommendation called out). + +func TestNormalize_AcceptsCanonicalInputs(t *testing.T) { + cases := []struct { + name string + in string + out string + }{ + {"already-normalised ASCII", "vitalik.eth", "vitalik.eth"}, + {"case-folds ASCII", "VITALIK.ETH", "vitalik.eth"}, + {"mixed case", "ViTaLiK.eTh", "vitalik.eth"}, + {"non-ASCII passes through", "öbb.eth", "öbb.eth"}, + {"non-ASCII case-folds", "ÖBB.eth", "öbb.eth"}, + {"underscore at label start", "_foo.eth", "_foo.eth"}, + {"emoji label", "💷pound.eth", "💷pound.eth"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := Normalize(c.in) + require.NoError(t, err) + require.Equal(t, c.out, got) + }) + } +} + +func TestNormalize_RejectsENSIP15Violations(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"empty label / leading dot", ".eth"}, + {"empty label in middle", "foo..eth"}, + {"empty label / trailing dot", "foo."}, + {"bare dot", "."}, + {"XX-- label extension", "te--st.eth"}, + {"underscore mid-label", "a_b.eth"}, + {"whole-script confusable", "apple.дррӏе.аррӏе.aррӏе"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Normalize(c.in) + require.Error(t, err, "expected ENSIP-15 to reject %q", c.in) + }) + } +} + +// ENSIP-15 differs from IDNA on Arabic-Indic digits: IDNA mapped them to +// ASCII digits, ENSIP-15 maps Extended Arabic-Indic (U+06F0-U+06F9) to +// Arabic-Indic (U+0660-U+0669) but never folds to ASCII. The resulting +// NameHash therefore differs from a pre-ENSIP-15 IDNA hash. This test pins +// the new behaviour so future library bumps don't silently regress. +func TestNormalize_ArabicIndicDigitsNotFoldedToASCII(t *testing.T) { + got, err := Normalize("۰۱۲۳۷۸۹.eth") + require.NoError(t, err) + require.NotEqual(t, "0123789.eth", got, "digits must NOT be ASCII-folded") + require.Equal(t, "٠١٢٣٧٨٩.eth", got) +} + +// Sanity check that Normalize errors are still wrapped with the +// "failed to ensip-15 normalize" prefix so the existing error-string +// behaviour at the API boundary is stable. +func TestNormalize_ErrorIsWrapped(t *testing.T) { + _, err := Normalize(".eth") + require.Error(t, err) + require.Contains(t, err.Error(), "ensip-15") + // Underlying ensip15 error is preserved via errors.Unwrap. + require.NotNil(t, errors.Unwrap(err)) +} diff --git a/registry.go b/registry.go index 9251e50..96d8100 100644 --- a/registry.go +++ b/registry.go @@ -22,9 +22,9 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/auctionregistrar" - "github.com/wealdtech/go-ens/v3/contracts/registry" - "github.com/wealdtech/go-ens/v3/util" + "github.com/wealdtech/go-ens/v4/contracts/auctionregistrar" + "github.com/wealdtech/go-ens/v4/contracts/registry" + "github.com/wealdtech/go-ens/v4/util" ) // Registry is the structure for the registry contract. diff --git a/resolver.go b/resolver.go index d6b03c3..b1d95b9 100644 --- a/resolver.go +++ b/resolver.go @@ -17,6 +17,7 @@ package ens import ( "bytes" "compress/zlib" + "context" "errors" "io" "math/big" @@ -25,11 +26,9 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/resolver" + "github.com/wealdtech/go-ens/v4/contracts/resolver" ) -var zeroHash = make([]byte, 32) - // UnknownAddress is the address to which unknown entries resolve. var UnknownAddress = common.HexToAddress("00") @@ -92,11 +91,18 @@ func NewResolverAt(backend bind.ContractBackend, domain string, address common.A } // PublicResolverAddress obtains the address of the public resolver for a chain. -func PublicResolverAddress(backend bind.ContractBackend) (common.Address, error) { - return Resolve(backend, "resolver.eth") +func PublicResolverAddress(ctx context.Context, backend bind.ContractBackend) (common.Address, error) { + return Resolve(ctx, backend, "resolver.eth") } // Address returns the Ethereum address of the domain. +// +// This reads the addr(bytes32) record directly from the resolver contract this +// Resolver is bound to (located via the registry when the Resolver was +// created). It does NOT perform ENSIP-10 wildcard resolution or follow ERC-3668 +// CCIP-Read, so names whose addresses live off-chain or on an L2 return the +// zero address or a raw revert error here. For wildcard- and CCIP-Read-aware +// resolution use Resolve or UniversalResolver.ResolveAddress instead. func (r *Resolver) Address() (common.Address, error) { nameHash, err := NameHash(r.domain) if err != nil { @@ -180,11 +186,25 @@ func (r *Resolver) InterfaceImplementer(interfaceID [4]byte) (common.Address, er return r.Contract.InterfaceImplementer(nil, nameHash, interfaceID) } -// Resolve resolves an ENS name in to an Etheruem address. -// This will return an error if the name is not found or otherwise 0. -func Resolve(backend bind.ContractBackend, input string) (common.Address, error) { +// Resolve resolves an ENS name into an Ethereum address, or parses input +// directly if it is already a hex address. +// +// If input contains a dot it is treated as an ENS name and resolved through +// the ENS UniversalResolver, so ENSIP-10 wildcard resolution and ERC-3668 +// CCIP-Read are followed transparently — names backed by offchain or L2 data +// resolve the same way as fully on-chain names. If input contains no dot it is +// treated as a literal hex address and returned after validation, without any +// on-chain lookup. +// +// ctx is honoured for cancellation and deadline propagation, flowing through +// the CCIP-Read hops, so a caller that sets a per-request deadline can cap the +// total resolution time end-to-end. +// +// It returns an error if the name is not found or otherwise resolves to the +// zero address. +func Resolve(ctx context.Context, backend bind.ContractBackend, input string) (common.Address, error) { if strings.Contains(input, ".") { - return resolveName(backend, input) + return resolveName(ctx, backend, input) } if (strings.HasPrefix(input, "0x") && len(input) > 42) || (!strings.HasPrefix(input, "0x") && len(input) > 40) { return UnknownAddress, errors.New("address too long") @@ -197,38 +217,15 @@ func Resolve(backend bind.ContractBackend, input string) (common.Address, error) return address, nil } -func resolveName(backend bind.ContractBackend, input string) (common.Address, error) { - nameHash, err := NameHash(input) - if err != nil { +func resolveName(ctx context.Context, backend bind.ContractBackend, input string) (common.Address, error) { + if _, err := NameHash(input); err != nil { return UnknownAddress, err } - if bytes.Equal(nameHash[:], zeroHash) { - return UnknownAddress, errors.New("bad name") - } - address, err := resolveHash(backend, input) + ur, err := NewUniversalResolver(ctx, backend) if err != nil { return UnknownAddress, err } - - return address, nil -} - -func resolveHash(backend bind.ContractBackend, domain string) (common.Address, error) { - resolver, err := NewResolver(backend, domain) - if err != nil { - return UnknownAddress, err - } - - // Resolve the domain. - address, err := resolver.Address() - if err != nil { - return UnknownAddress, err - } - if bytes.Equal(address.Bytes(), UnknownAddress.Bytes()) { - return UnknownAddress, errors.New("no address") - } - - return address, nil + return ur.ResolveAddress(ctx, input) } // SetText sets the text associated with a name. diff --git a/resolver_test.go b/resolver_test.go index 3da9009..38ef1b7 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -15,89 +15,91 @@ package ens import ( + "context" "encoding/hex" "testing" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var client, _ = ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") - func TestResolveEmpty(t *testing.T) { - _, err := Resolve(client, "") + client := mainnetClient(t) + _, err := Resolve(context.Background(), client, "") assert.NotNil(t, err, "Resolved empty name") } func TestResolveZero(t *testing.T) { - _, err := Resolve(client, "0") + client := mainnetClient(t) + _, err := Resolve(context.Background(), client, "0") assert.NotNil(t, err, "Resolved empty name") } func TestResolveNotPresent(t *testing.T) { - _, err := Resolve(client, "sirnotappearinginthisregistry.eth") + client := mainnetClient(t) + _, err := Resolve(context.Background(), client, "sirnotappearinginthisregistry.eth") require.NotNil(t, err, "Resolved name that does not exist") assert.Equal(t, "unregistered name", err.Error(), "Unexpected error") } // func TestResolveNoResolver(t *testing.T) { -// _, err := Resolve(client, "noresolver.eth") +// client := mainnetClient(t) +// _, err := Resolve(context.Background(), client, "noresolver.eth") // require.NotNil(t, err, "Resolved name without a resolver") // assert.Equal(t, "no resolver", err.Error(), "Unexpected error") // } func TestResolveBadResolver(t *testing.T) { - _, err := Resolve(client, "resolvestozero.eth") + client := mainnetClient(t) + _, err := Resolve(context.Background(), client, "resolvestozero.eth") require.NotNil(t, err, "Resolved name with a bad resolver") assert.Equal(t, "no address", err.Error(), "Unexpected error") } func TestResolveTestEnsTest(t *testing.T) { + client := mainnetClient(t) expected := "ed96dd3be847b387217ef9de5b20d8392a6cdf40" - actual, err := Resolve(client, "test.enstest.eth") + actual, err := Resolve(context.Background(), client, "test.enstest.eth") require.Nil(t, err, "Error resolving name") assert.Equal(t, expected, hex.EncodeToString(actual[:]), "Did not receive expected result") } func TestResolveResolverEth(t *testing.T) { + client := mainnetClient(t) expected := "231b0ee14048e9dccd1d247744d114a4eb5e8e63" - actual, err := Resolve(client, "resolver.eth") - require.Nil(t, err, "Error resolving name") - assert.Equal(t, expected, hex.EncodeToString(actual[:]), "Did not receive expected result") -} - -func TestResolveEthereum(t *testing.T) { - expected := "de0b295669a9fd93d5f28d9ec85e40f4cb697bae" - actual, err := Resolve(client, "ethereum.eth") + actual, err := Resolve(context.Background(), client, "resolver.eth") require.Nil(t, err, "Error resolving name") assert.Equal(t, expected, hex.EncodeToString(actual[:]), "Did not receive expected result") } func TestResolveAddress(t *testing.T) { + client := mainnetClient(t) expected := "b8c2c29ee19d8307cb7255e1cd9cbde883a267d5" - actual, err := Resolve(client, "0xb8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5") + actual, err := Resolve(context.Background(), client, "0xb8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5") require.Nil(t, err, "Error resolving address") assert.Equal(t, expected, hex.EncodeToString(actual[:]), "Did not receive expected result") } func TestResolveShortAddress(t *testing.T) { + client := mainnetClient(t) expected := "0000000000000000000000000000000000000001" - actual, err := Resolve(client, "0x1") + actual, err := Resolve(context.Background(), client, "0x1") require.Nil(t, err, "Error resolving address") assert.Equal(t, expected, hex.EncodeToString(actual[:]), "Did not receive expected result") } func TestResolveHexString(t *testing.T) { - _, err := Resolve(client, "0xe32c6d1a964749b6de2130e20daed821a45b9e7261118801ff5319d0ffc6b54a") + client := mainnetClient(t) + _, err := Resolve(context.Background(), client, "0xe32c6d1a964749b6de2130e20daed821a45b9e7261118801ff5319d0ffc6b54a") assert.NotNil(t, err, "Resolved too-long hex string") } func TestReverseResolveTestEnsTest(t *testing.T) { + client := mainnetClient(t) expected := "nick.eth" address := common.HexToAddress("b8c2C29ee19D8307cb7255e1Cd9CbDE883A267d5") - actual, err := ReverseResolve(client, address) + actual, err := ReverseResolve(context.Background(), client, address) require.Nil(t, err, "Error resolving address") assert.Equal(t, expected, actual, "Did not receive expected result") } diff --git a/reverseregistrar.go b/reverseregistrar.go index bee1aeb..b6670c2 100644 --- a/reverseregistrar.go +++ b/reverseregistrar.go @@ -20,7 +20,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/wealdtech/go-ens/v3/contracts/reverseregistrar" + "github.com/wealdtech/go-ens/v4/contracts/reverseregistrar" ) // ReverseRegistrar is the structure for the reverse registrar. diff --git a/reverseresolver.go b/reverseresolver.go index 791863e..60cdc3a 100644 --- a/reverseresolver.go +++ b/reverseresolver.go @@ -15,12 +15,13 @@ package ens import ( + "context" "errors" "fmt" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - "github.com/wealdtech/go-ens/v3/contracts/reverseresolver" + "github.com/wealdtech/go-ens/v4/contracts/reverseresolver" ) // ReverseResolver is the structure for the reverse resolver contract. @@ -95,30 +96,34 @@ func (r *ReverseResolver) Name(address common.Address) (string, error) { } // Format provides a string version of an address, reverse resolving it if possible. -func Format(backend bind.ContractBackend, address common.Address) string { - result, err := ReverseResolve(backend, address) +func Format(ctx context.Context, backend bind.ContractBackend, address common.Address) string { + result, err := ReverseResolve(ctx, backend, address) if err != nil { result = address.Hex() } return result } -// ReverseResolve resolves an address in to an ENS name. -// This will return an error if the name is not found or otherwise 0. -func ReverseResolve(backend bind.ContractBackend, address common.Address) (string, error) { - resolver, err := NewReverseResolverFor(backend, address) +// ReverseResolve resolves an address in to an ENS name. Resolution flows +// through the ENS UniversalResolver, so ENSIP-10 wildcard resolution and +// ERC-3668 CCIP-Read are handled transparently and reverse records stored +// offchain or on an L2 resolve the same way as fully on-chain reverse records. +// +// ctx is honoured for cancellation and deadline propagation through the +// CCIP-Read hops. +// +// This will return an error if no primary name is set for the address. +func ReverseResolve(ctx context.Context, backend bind.ContractBackend, address common.Address) (string, error) { + ur, err := NewUniversalResolver(ctx, backend) if err != nil { return "", err } - - // Resolve the name. - name, err := resolver.Name(address) + name, _, _, err := ur.Reverse(ctx, address.Bytes(), CoinTypeETH) if err != nil { return "", err } if name == "" { - err = errors.New("no resolution") + return "", errors.New("no resolution") } - - return name, err + return name, nil } diff --git a/reverseresolver_test.go b/reverseresolver_test.go index b61609f..c14ca90 100644 --- a/reverseresolver_test.go +++ b/reverseresolver_test.go @@ -15,12 +15,12 @@ package ens_test import ( + "context" "testing" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" - ens "github.com/wealdtech/go-ens/v3" + ens "github.com/wealdtech/go-ens/v4" ) // TestReverseResolve tests the reverse resolution functionality. @@ -34,7 +34,9 @@ func TestReverseResolve(t *testing.T) { { name: "NoResolver", address: common.Address{}, - err: "not a resolver", + // UniversalResolver returns an empty primary name for the zero + // address; ReverseResolve translates that to "no resolution". + err: "no resolution", }, { name: "NoReverseRecord", @@ -48,12 +50,11 @@ func TestReverseResolve(t *testing.T) { }, } - client, err := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") - require.NoError(t, err) + client := mainnetClient(t) for _, test := range tests { t.Run(test.name, func(t *testing.T) { - res, err := ens.ReverseResolve(client, test.address) + res, err := ens.ReverseResolve(context.Background(), client, test.address) if test.err != "" { require.EqualError(t, err, test.err) } else { diff --git a/tokenid.go b/tokenid.go index 25cc5a5..a2ea7a7 100644 --- a/tokenid.go +++ b/tokenid.go @@ -15,6 +15,7 @@ package ens import ( + "context" "errors" "fmt" @@ -25,11 +26,11 @@ import ( // DeriveTokenID derive tokenID from the ENS domain. // // The tokenID of the ENS name is simply the uint256 representation of the tokenID of ERC721. -func DeriveTokenID(backend bind.ContractBackend, domain string) (string, error) { +func DeriveTokenID(ctx context.Context, backend bind.ContractBackend, domain string) (string, error) { if domain == "" { return "", errors.New("empty domain") } - _, err := Resolve(backend, domain) + _, err := Resolve(ctx, backend, domain) if err != nil { return "", err } diff --git a/tokenid_test.go b/tokenid_test.go index eb08d3d..b48a11b 100644 --- a/tokenid_test.go +++ b/tokenid_test.go @@ -1,9 +1,9 @@ package ens import ( + "context" "testing" - "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" ) @@ -20,9 +20,12 @@ func TestDeriveTokenId(t *testing.T) { input: "vitalik.eth", }, { + // Long, intentionally-unregistered .eth label used as the unregistered-name + // fixture. Any .eth name is in principle registerable, so picking a long + // label keeps the registration cost high and the chance of collision low. name: "Invalid ENS domain", expected: "", - input: "foo.bar", + input: "this-name-is-not-registered-go-ens-test-fixture-do-not-register.eth", err: "unregistered name", }, { @@ -32,11 +35,10 @@ func TestDeriveTokenId(t *testing.T) { err: "empty domain", }, } - client, err := ethclient.Dial("https://mainnet.infura.io/v3/831a5442dc2e4536a9f8dee4ea1707a6") - require.NoError(t, err) + client := mainnetClient(t) for _, test := range tests { t.Run(test.name, func(t *testing.T) { - actual, err := DeriveTokenID(client, test.input) + actual, err := DeriveTokenID(context.Background(), client, test.input) if test.err != "" { require.EqualError(t, err, test.err) } else { diff --git a/universalresolver.go b/universalresolver.go new file mode 100644 index 0000000..7ae4c48 --- /dev/null +++ b/universalresolver.go @@ -0,0 +1,252 @@ +// 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 ens + +import ( + "context" + "errors" + "fmt" + "math/big" + "net/http" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/wealdtech/go-ens/v4/ccipread" + "github.com/wealdtech/go-ens/v4/contracts/universalresolver" +) + +// UniversalResolverContractAddress is the proxy address of the ENS +// UniversalResolver. The same vanity address is used on Ethereum mainnet +// and L1 testnets where the ENS DAO has deployed the proxy (Sepolia, Holesky). +const UniversalResolverContractAddress = "0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe" + +// CoinTypeETH is SLIP-0044 coin type 60: native Ethereum addresses. +const CoinTypeETH = uint64(60) + +// addrSelector is keccak256("addr(bytes32)")[:4]: the legacy ETH-address +// resolver profile. UniversalResolver.resolve forwards calldata starting with +// this selector to the discovered resolver and returns the abi-encoded result. +var addrSelector = [4]byte{0x3b, 0x3b, 0x57, 0xde} + +// knownURChains lists the chain IDs on which the canonical UR proxy address +// is deployed by the ENS DAO. Callers on other chains must supply an explicit +// address via WithAddress. +var knownURChains = map[uint64]struct{}{ + 1: {}, // Ethereum mainnet + 11155111: {}, // Sepolia testnet + 17000: {}, // Holesky testnet +} + +// UnknownChainError is returned by NewUniversalResolver when the backend +// reports a chain ID without a known UR deployment at the canonical address. +type UnknownChainError struct { + ChainID *big.Int +} + +func (e *UnknownChainError) Error() string { + return fmt.Sprintf("universal resolver: no canonical deployment on chain ID %s; supply an explicit address with WithAddress", e.ChainID) +} + +// Parameter is a functional option for NewUniversalResolver. +type Parameter func(*parameters) + +type parameters struct { + address *common.Address + httpClient *http.Client + maxHops int +} + +// WithAddress binds the resolver to an explicit UniversalResolver address +// instead of the canonical proxy. Use this on chains without a canonical +// deployment (devnets, forks, alt-L1s); it also skips the chain-ID guardrail. +func WithAddress(address common.Address) Parameter { + return func(p *parameters) { p.address = &address } +} + +// WithHTTPClient sets the HTTP client used for ERC-3668 CCIP-Read gateway +// requests. When unset, a default client is used. +func WithHTTPClient(client *http.Client) Parameter { + return func(p *parameters) { p.httpClient = client } +} + +// WithMaxHops caps the number of OffchainLookup redirects a single resolution +// may chain. When unset, the ccipread package default applies. +func WithMaxHops(hops int) Parameter { + return func(p *parameters) { p.maxHops = hops } +} + +// UniversalResolver wraps the ENS Universal Resolver and exposes resolution +// helpers that transparently follow ERC-3668 OffchainLookup reverts via the +// ccipread package. +type UniversalResolver struct { + backend bind.ContractBackend + addr common.Address + abi abi.ABI + httpClient *http.Client + maxHops int +} + +// NewUniversalResolver returns a UniversalResolver for the chain reached +// through backend. It honours ctx for the chain-ID probe, so a cancelled or +// expired ctx surfaces as ctx.Err(). +// +// By default it binds to the canonical proxy address and, when the backend +// exposes a chain ID (ethclient.Client and most production backends do), +// verifies the chain has a known UR deployment — returning *UnknownChainError +// otherwise. Supply WithAddress to target a non-standard deployment (devnet, +// fork, alt-L1), which also skips the chain-ID guardrail. +func NewUniversalResolver(ctx context.Context, backend bind.ContractBackend, params ...Parameter) (*UniversalResolver, error) { + var p parameters + for _, param := range params { + param(&p) + } + + addr := common.HexToAddress(UniversalResolverContractAddress) + if p.address != nil { + addr = *p.address + } else if cid, ok := backend.(interface { + ChainID(context.Context) (*big.Int, error) + }); ok { + // Only guard the canonical address; an explicit WithAddress opts out. + chainID, err := cid.ChainID(ctx) + if err == nil && chainID != nil { + if _, known := knownURChains[chainID.Uint64()]; !known { + return nil, &UnknownChainError{ChainID: chainID} + } + } else if err != nil && ctx.Err() != nil { + // Surface ctx cancellation/deadline from the probe itself. + return nil, ctx.Err() + } + } + + parsed, err := universalresolver.ContractMetaData.GetAbi() + if err != nil { + return nil, fmt.Errorf("parse universal resolver abi: %w", err) + } + return &UniversalResolver{ + backend: backend, + addr: addr, + abi: *parsed, + httpClient: p.httpClient, + maxHops: p.maxHops, + }, nil +} + +// callOptions builds the ccipread options from the resolver's configuration, +// returning nil when nothing has been customised so ccipread uses its defaults. +func (u *UniversalResolver) callOptions() *ccipread.Options { + if u.httpClient == nil && u.maxHops == 0 { + return nil + } + return &ccipread.Options{HTTPClient: u.httpClient, MaxRedirects: u.maxHops} +} + +// Address returns the on-chain address of the bound UniversalResolver. +func (u *UniversalResolver) Address() common.Address { + return u.addr +} + +// Resolve performs UniversalResolver.resolve(name, callData) using the chain +// connected via the backend, transparently following any OffchainLookup +// reverts. The returned bytes are the abi-encoded return value of the +// downstream resolver call (e.g. for `addr(bytes32)` the bytes decode as +// `address`); resolverAddr is the resolver contract that produced it. +func (u *UniversalResolver) Resolve(ctx context.Context, name string, callData []byte) ([]byte, common.Address, error) { + dnsName, err := DNSEncode(name) + if err != nil { + return nil, common.Address{}, fmt.Errorf("dns-encode %q: %w", name, err) + } + input, err := u.abi.Pack("resolve", dnsName, callData) + if err != nil { + return nil, common.Address{}, fmt.Errorf("pack resolve: %w", err) + } + out, err := ccipread.Call(ctx, u.backend, u.addr, input, u.callOptions()) + if err != nil { + return nil, common.Address{}, translateURRevert(err) + } + values, err := u.abi.Unpack("resolve", out) + if err != nil { + return nil, common.Address{}, fmt.Errorf("unpack resolve: %w", err) + } + if len(values) != 2 { + return nil, common.Address{}, errors.New("universal resolver: unexpected resolve output arity") + } + result, ok := values[0].([]byte) + if !ok { + return nil, common.Address{}, fmt.Errorf("universal resolver: resolve result has unexpected type %T", values[0]) + } + resolver, ok := values[1].(common.Address) + if !ok { + return nil, common.Address{}, fmt.Errorf("universal resolver: resolve resolver has unexpected type %T", values[1]) + } + return result, resolver, nil +} + +// ResolveAddress resolves an ENS name to its primary Ethereum address by +// invoking the legacy `addr(bytes32)` resolver profile through the +// UniversalResolver. +func (u *UniversalResolver) ResolveAddress(ctx context.Context, name string) (common.Address, error) { + nameHash, err := NameHash(name) + if err != nil { + return UnknownAddress, err + } + callData := make([]byte, 4+32) + copy(callData[:4], addrSelector[:]) + copy(callData[4:], nameHash[:]) + result, _, err := u.Resolve(ctx, name, callData) + if err != nil { + return UnknownAddress, err + } + if len(result) == 0 { + return UnknownAddress, ErrNoAddress + } + if len(result) != 32 { + return UnknownAddress, fmt.Errorf("addr result has unexpected length %d", len(result)) + } + addr := common.BytesToAddress(result) + if addr == UnknownAddress { + return UnknownAddress, ErrNoAddress + } + return addr, nil +} + +// Reverse performs UniversalResolver.reverse(lookupAddress, coinType) and +// returns the primary name, the resolver address, and the reverse resolver +// address. CCIP-Read is followed transparently. +func (u *UniversalResolver) Reverse(ctx context.Context, address []byte, coinType uint64) (string, common.Address, common.Address, error) { + input, err := u.abi.Pack("reverse", address, new(big.Int).SetUint64(coinType)) + if err != nil { + return "", common.Address{}, common.Address{}, fmt.Errorf("pack reverse: %w", err) + } + out, err := ccipread.Call(ctx, u.backend, u.addr, input, u.callOptions()) + if err != nil { + return "", common.Address{}, common.Address{}, translateURRevert(err) + } + values, err := u.abi.Unpack("reverse", out) + if err != nil { + return "", common.Address{}, common.Address{}, fmt.Errorf("unpack reverse: %w", err) + } + if len(values) != 3 { + return "", common.Address{}, common.Address{}, errors.New("universal resolver: unexpected reverse output arity") + } + name, ok := values[0].(string) + if !ok { + return "", common.Address{}, common.Address{}, fmt.Errorf("universal resolver: reverse name has unexpected type %T", values[0]) + } + resolver, ok := values[1].(common.Address) + if !ok { + return "", common.Address{}, common.Address{}, fmt.Errorf("universal resolver: reverse resolver has unexpected type %T", values[1]) + } + reverseResolver, ok := values[2].(common.Address) + if !ok { + return "", common.Address{}, common.Address{}, fmt.Errorf("universal resolver: reverse reverseResolver has unexpected type %T", values[2]) + } + return name, resolver, reverseResolver, nil +} diff --git a/universalresolver_reads.go b/universalresolver_reads.go new file mode 100644 index 0000000..a7bb530 --- /dev/null +++ b/universalresolver_reads.go @@ -0,0 +1,142 @@ +// 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 ens + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/wealdtech/go-ens/v4/contracts/resolver" +) + +// resolverABI is the ABI of the legacy PublicResolver, used to pack the +// calldata for individual resolver profiles (text, contenthash, multicoin +// addr) that UniversalResolver.resolve forwards to the discovered resolver. +// Parsed once at init; the profile signatures are stable across resolver +// versions. +var resolverABI abi.ABI + +func init() { + parsed, err := resolver.ContractMetaData.GetAbi() + if err != nil { + // The binding ABI is a compile-time constant string; a parse failure + // here means the generated binding is broken, which is a build-level + // bug rather than a runtime condition. + panic(fmt.Sprintf("ens: parse resolver abi: %v", err)) + } + resolverABI = *parsed +} + +// Text resolves a text record for a name through the UniversalResolver. Unlike +// Resolver.Text, this follows ENSIP-10 wildcard resolution and ERC-3668 +// CCIP-Read, so offchain and L2 text records resolve correctly. +// +// This is the read half of the unified read path: it builds calldata for the +// resolver's text(bytes32,string) profile and hands it to the same resolve() +// choke point as Address and Contenthash, so wildcard/CCIP behaviour is +// identical across every profile. +func (u *UniversalResolver) Text(ctx context.Context, name, key string) (string, error) { + nameHash, err := NameHash(name) + if err != nil { + return "", err + } + callData, err := resolverABI.Pack("text", nameHash, key) + if err != nil { + return "", fmt.Errorf("pack text: %w", err) + } + result, _, err := u.Resolve(ctx, name, callData) + if err != nil { + return "", err + } + if len(result) == 0 { + return "", nil + } + values, err := resolverABI.Unpack("text", result) + if err != nil { + return "", fmt.Errorf("unpack text: %w", err) + } + if len(values) != 1 { + return "", fmt.Errorf("universal resolver: unexpected text output arity") + } + out, ok := values[0].(string) + if !ok { + return "", fmt.Errorf("universal resolver: text result has unexpected type %T", values[0]) + } + return out, nil +} + +// Contenthash resolves the contenthash record for a name through the +// UniversalResolver, following ENSIP-10 wildcard resolution and ERC-3668 +// CCIP-Read. +func (u *UniversalResolver) Contenthash(ctx context.Context, name string) ([]byte, error) { + nameHash, err := NameHash(name) + if err != nil { + return nil, err + } + callData, err := resolverABI.Pack("contenthash", nameHash) + if err != nil { + return nil, fmt.Errorf("pack contenthash: %w", err) + } + result, _, err := u.Resolve(ctx, name, callData) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, nil + } + values, err := resolverABI.Unpack("contenthash", result) + if err != nil { + return nil, fmt.Errorf("unpack contenthash: %w", err) + } + if len(values) != 1 { + return nil, fmt.Errorf("universal resolver: unexpected contenthash output arity") + } + out, ok := values[0].([]byte) + if !ok { + return nil, fmt.Errorf("universal resolver: contenthash result has unexpected type %T", values[0]) + } + return out, nil +} + +// MultiAddress resolves the address of a name for an arbitrary SLIP-0044 coin +// type through the UniversalResolver, following ENSIP-10 wildcard resolution +// and ERC-3668 CCIP-Read. It invokes the resolver's addr(bytes32,uint256) +// profile. For coin type 60 (ETH) prefer ResolveAddress, which returns a typed +// common.Address. +func (u *UniversalResolver) MultiAddress(ctx context.Context, name string, coinType uint64) ([]byte, error) { + nameHash, err := NameHash(name) + if err != nil { + return nil, err + } + callData, err := resolverABI.Pack("addr0", nameHash, new(big.Int).SetUint64(coinType)) + if err != nil { + return nil, fmt.Errorf("pack addr(bytes32,uint256): %w", err) + } + result, _, err := u.Resolve(ctx, name, callData) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, ErrNoAddress + } + values, err := resolverABI.Unpack("addr0", result) + if err != nil { + return nil, fmt.Errorf("unpack addr(bytes32,uint256): %w", err) + } + if len(values) != 1 { + return nil, fmt.Errorf("universal resolver: unexpected addr output arity") + } + out, ok := values[0].([]byte) + if !ok { + return nil, fmt.Errorf("universal resolver: addr result has unexpected type %T", values[0]) + } + return out, nil +} diff --git a/universalresolver_test.go b/universalresolver_test.go new file mode 100644 index 0000000..050faba --- /dev/null +++ b/universalresolver_test.go @@ -0,0 +1,178 @@ +// 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 ens_test + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + ens "github.com/wealdtech/go-ens/v4" +) + +// chainIDBackend embeds a nil ContractBackend and overrides ChainID, so the +// constructor's type-assertion fires but no method outside ChainID can be +// called (which is fine because the guardrail returns before any other call). +type chainIDBackend struct { + bind.ContractBackend + chainID *big.Int +} + +func (b *chainIDBackend) ChainID(_ context.Context) (*big.Int, error) { + return b.chainID, nil +} + +func TestNewUniversalResolver_AcceptsMainnet(t *testing.T) { + ur, err := ens.NewUniversalResolver(context.Background(), &chainIDBackend{chainID: big.NewInt(1)}) + require.NoError(t, err) + require.NotNil(t, ur) +} + +func TestNewUniversalResolver_AcceptsSepolia(t *testing.T) { + ur, err := ens.NewUniversalResolver(context.Background(), &chainIDBackend{chainID: big.NewInt(11155111)}) + require.NoError(t, err) + require.NotNil(t, ur) +} + +func TestNewUniversalResolver_AcceptsHolesky(t *testing.T) { + ur, err := ens.NewUniversalResolver(context.Background(), &chainIDBackend{chainID: big.NewInt(17000)}) + require.NoError(t, err) + require.NotNil(t, ur) +} + +func TestNewUniversalResolver_RejectsUnknownChain(t *testing.T) { + _, err := ens.NewUniversalResolver(context.Background(), &chainIDBackend{chainID: big.NewInt(8453)}) // Base + require.Error(t, err) + var typed *ens.UnknownChainError + require.ErrorAs(t, err, &typed) + require.Equal(t, int64(8453), typed.ChainID.Int64()) + require.Contains(t, err.Error(), "WithAddress") +} + +// NewUniversalResolverAt skips the chain-ID check by design — useful for +// devnets / forks / alt-L1s where the UR is deployed at a non-canonical +// address. +func TestNewUniversalResolverAt_AcceptsAnyChain(t *testing.T) { + custom := common.HexToAddress("0x000000000000000000000000000000000000bEEF") + ur, err := ens.NewUniversalResolver(context.Background(), &chainIDBackend{chainID: big.NewInt(8453)}, ens.WithAddress(custom)) + require.NoError(t, err) + require.Equal(t, custom, ur.Address()) +} + +// slowChainIDBackend blocks on ctx until released, then returns its chain ID. +// Used to assert that NewUniversalResolverContext propagates ctx cancellation +// into the chain-ID probe instead of relying on context.Background(). +type slowChainIDBackend struct { + bind.ContractBackend + chainID *big.Int + release chan struct{} +} + +func (b *slowChainIDBackend) ChainID(ctx context.Context) (*big.Int, error) { + select { + case <-b.release: + return b.chainID, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// TestNewUniversalResolverContext_PropagatesCancellation asserts that a +// cancelled ctx surfaces as ctx.Err() from the constructor's chain-ID probe, +// rather than the probe running to completion on an uncancellable background +// context. +func TestNewUniversalResolverContext_PropagatesCancellation(t *testing.T) { + backend := &slowChainIDBackend{ + chainID: big.NewInt(1), + release: make(chan struct{}), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := ens.NewUniversalResolver(ctx, backend) + require.ErrorIs(t, err, context.Canceled, + "a cancelled ctx must surface as context.Canceled from the chain-ID probe") +} + +// TestNewUniversalResolverContext_HonoursDeadline mirrors the cancellation +// test but with a context deadline that expires during the probe. +func TestNewUniversalResolverContext_HonoursDeadline(t *testing.T) { + backend := &slowChainIDBackend{ + chainID: big.NewInt(1), + release: make(chan struct{}), + } + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + + _, err := ens.NewUniversalResolver(ctx, backend) + require.ErrorIs(t, err, context.DeadlineExceeded, + "an expired deadline must surface as context.DeadlineExceeded from the chain-ID probe") +} + +// TestNewUniversalResolverContext_AcceptsKnownChain ensures the context-aware +// constructor still returns a usable resolver on the happy path. +func TestNewUniversalResolverContext_AcceptsKnownChain(t *testing.T) { + ur, err := ens.NewUniversalResolver(context.Background(), &chainIDBackend{chainID: big.NewInt(1)}) + require.NoError(t, err) + require.NotNil(t, ur) +} + +// TestUniversalResolverIntegrationName is the canonical ENSv2 readiness +// check from https://docs.ens.domains/web/ensv2-readiness/. A library that +// resolves through the UniversalResolver returns the v2 sentinel address; +// a library that still uses legacy registry-walking returns the v1 sentinel. +func TestUniversalResolverIntegrationName(t *testing.T) { + const ( + name = "ur.integration-tests.eth" + expectedV2 = "0x2222222222222222222222222222222222222222" + legacyV1Marker = "0x1111111111111111111111111111111111111111" + ) + client := mainnetClient(t) + + addr, err := ens.Resolve(context.Background(), client, name) + require.NoError(t, err, "Resolve(%s) failed", name) + + got := strings.ToLower(addr.Hex()) + require.NotEqual(t, strings.ToLower(legacyV1Marker), got, + "got the legacy v1 sentinel %s — resolution did not route through the UniversalResolver", legacyV1Marker) + require.Equal(t, strings.ToLower(expectedV2), got, + "expected ENSv2 sentinel %s, got %s", expectedV2, got) +} + +// TestUniversalResolverCCIPRead exercises the OffchainLookup path: this +// name resolves only after the client follows an ERC-3668 revert and queries +// a CCIP-Read gateway. +func TestUniversalResolverCCIPRead(t *testing.T) { + const ( + name = "test.offchaindemo.eth" + expected = "0x779981590E7Ccc0CFAe8040Ce7151324747cDb97" + ) + client := mainnetClient(t) + + addr, err := ens.Resolve(context.Background(), client, name) + require.NoError(t, err, "Resolve(%s) failed", name) + require.Equal(t, strings.ToLower(expected), strings.ToLower(addr.Hex())) +} + +// TestUniversalResolverDirectResolve checks the lower-level helper used by +// Resolve, asserting that calling UniversalResolver.ResolveAddress directly +// produces the same result as the top-level Resolve entrypoint. +func TestUniversalResolverDirectResolve(t *testing.T) { + client := mainnetClient(t) + ur, err := ens.NewUniversalResolver(context.Background(), client) + require.NoError(t, err) + + addr, err := ur.ResolveAddress(context.Background(), "ur.integration-tests.eth") + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0x2222222222222222222222222222222222222222"), addr) +} diff --git a/urerrors.go b/urerrors.go new file mode 100644 index 0000000..dbd64b8 --- /dev/null +++ b/urerrors.go @@ -0,0 +1,264 @@ +// 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 ens + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// ErrUnregistered is returned when a name has no resolver in the registry. +// Callers may compare against it with errors.Is. +var ErrUnregistered = errors.New("unregistered name") + +// ErrNoAddress is returned when a resolver is found but the address record +// is missing or zero. +var ErrNoAddress = errors.New("no address") + +// ResolverNotFoundError corresponds to the UniversalResolver custom error +// `ResolverNotFound(bytes)`. It is reported when no resolver is set anywhere +// up the parent chain of the requested name. +type ResolverNotFoundError struct { + // DNSName is the DNS-encoded name as passed to resolve(). + DNSName []byte +} + +func (e *ResolverNotFoundError) Error() string { return "unregistered name" } +func (e *ResolverNotFoundError) Unwrap() error { return ErrUnregistered } + +// ResolverNotContractError corresponds to `ResolverNotContract(bytes,address)`: +// the registry pointed at an address that holds no code. +type ResolverNotContractError struct { + DNSName []byte + Resolver common.Address +} + +func (e *ResolverNotContractError) Error() string { + return fmt.Sprintf("resolver address has no code: %s", e.Resolver.Hex()) +} + +// UnsupportedResolverProfileError corresponds to +// `UnsupportedResolverProfile(bytes4)`: the resolver did not implement the +// requested function selector. +type UnsupportedResolverProfileError struct { + Selector [4]byte +} + +func (e *UnsupportedResolverProfileError) Error() string { + return fmt.Sprintf("resolver does not implement profile 0x%s", hex.EncodeToString(e.Selector[:])) +} + +// ResolverRevertError corresponds to `ResolverError(bytes)`: the downstream +// resolver itself reverted; the inner revert bytes are preserved verbatim. +type ResolverRevertError struct { + Data []byte +} + +func (e *ResolverRevertError) Error() string { + return fmt.Sprintf("resolver reverted: 0x%s", hex.EncodeToString(e.Data)) +} + +// EmptyAddressError corresponds to `EmptyAddress()`: thrown by reverse() when +// the lookup address is empty. +type EmptyAddressError struct{} + +func (e *EmptyAddressError) Error() string { return "no address" } +func (e *EmptyAddressError) Unwrap() error { return ErrNoAddress } + +// HTTPGatewayError corresponds to `HttpError(uint16,string)`: a CCIP-Read +// gateway returned a non-success status whose body the UR forwarded. +type HTTPGatewayError struct { + Status uint16 + Message string +} + +func (e *HTTPGatewayError) Error() string { + return fmt.Sprintf("ccip-read gateway HTTP %d: %s", e.Status, e.Message) +} + +// ReverseAddressMismatchError corresponds to `ReverseAddressMismatch(string,bytes)`: +// the reverse-resolved name's forward record does not point back at the +// queried address. +type ReverseAddressMismatchError struct { + Name string + Address []byte +} + +func (e *ReverseAddressMismatchError) Error() string { + return fmt.Sprintf("reverse name %q does not resolve back to %s", e.Name, "0x"+hex.EncodeToString(e.Address)) +} + +// Custom-error selectors. Computed at init from the canonical signatures. +var ( + selResolverNotFound [4]byte + selResolverNotContract [4]byte + selUnsupportedResolverProfile [4]byte + selResolverError [4]byte + selEmptyAddress [4]byte + selHTTPError [4]byte + selReverseAddressMismatch [4]byte +) + +// Argument lists for decoding error payloads. +var ( + argsResolverNotFound abi.Arguments + argsResolverNotContract abi.Arguments + argsUnsupportedResolverProfile abi.Arguments + argsResolverError abi.Arguments + argsHTTPError abi.Arguments + argsReverseAddressMismatch abi.Arguments +) + +func init() { + bytesT, _ := abi.NewType("bytes", "", nil) + bytes4T, _ := abi.NewType("bytes4", "", nil) + addressT, _ := abi.NewType("address", "", nil) + uint16T, _ := abi.NewType("uint16", "", nil) + stringT, _ := abi.NewType("string", "", nil) + + argsResolverNotFound = abi.Arguments{{Type: bytesT}} + argsResolverNotContract = abi.Arguments{{Type: bytesT}, {Type: addressT}} + argsUnsupportedResolverProfile = abi.Arguments{{Type: bytes4T}} + argsResolverError = abi.Arguments{{Type: bytesT}} + argsHTTPError = abi.Arguments{{Type: uint16T}, {Type: stringT}} + argsReverseAddressMismatch = abi.Arguments{{Type: stringT}, {Type: bytesT}} + + selResolverNotFound = errorSelector("ResolverNotFound(bytes)") + selResolverNotContract = errorSelector("ResolverNotContract(bytes,address)") + selUnsupportedResolverProfile = errorSelector("UnsupportedResolverProfile(bytes4)") + selResolverError = errorSelector("ResolverError(bytes)") + selEmptyAddress = errorSelector("EmptyAddress()") + selHTTPError = errorSelector("HttpError(uint16,string)") + selReverseAddressMismatch = errorSelector("ReverseAddressMismatch(string,bytes)") +} + +func errorSelector(sig string) [4]byte { + h := crypto.Keccak256([]byte(sig)) + var s [4]byte + copy(s[:], h[:4]) + return s +} + +// translateURRevert tries to decode a CallContract error as one of the +// UniversalResolver's typed custom errors. If recognised, it returns a typed +// Go error with a human-friendly message; otherwise it returns err unchanged. +func translateURRevert(err error) error { + if err == nil { + return nil + } + revert, ok := extractRevertData(err) + if !ok || len(revert) < 4 { + return err + } + var sel [4]byte + copy(sel[:], revert[:4]) + body := revert[4:] + + switch sel { + case selResolverNotFound: + out := &ResolverNotFoundError{} + if vals, derr := argsResolverNotFound.Unpack(body); derr == nil && len(vals) == 1 { + if name, ok := vals[0].([]byte); ok { + out.DNSName = name + } + } + return out + case selResolverNotContract: + out := &ResolverNotContractError{} + if vals, derr := argsResolverNotContract.Unpack(body); derr == nil && len(vals) == 2 { + if name, ok := vals[0].([]byte); ok { + out.DNSName = name + } + if a, ok := vals[1].(common.Address); ok { + out.Resolver = a + } + } + return out + case selUnsupportedResolverProfile: + out := &UnsupportedResolverProfileError{} + if vals, derr := argsUnsupportedResolverProfile.Unpack(body); derr == nil && len(vals) == 1 { + if s, ok := vals[0].([4]byte); ok { + out.Selector = s + } + } + return out + case selResolverError: + out := &ResolverRevertError{} + if vals, derr := argsResolverError.Unpack(body); derr == nil && len(vals) == 1 { + if data, ok := vals[0].([]byte); ok { + out.Data = data + } + } + return out + case selEmptyAddress: + return &EmptyAddressError{} + case selHTTPError: + out := &HTTPGatewayError{} + if vals, derr := argsHTTPError.Unpack(body); derr == nil && len(vals) == 2 { + if s, ok := vals[0].(uint16); ok { + out.Status = s + } + if m, ok := vals[1].(string); ok { + out.Message = m + } + } + return out + case selReverseAddressMismatch: + out := &ReverseAddressMismatchError{} + if vals, derr := argsReverseAddressMismatch.Unpack(body); derr == nil && len(vals) == 2 { + if n, ok := vals[0].(string); ok { + out.Name = n + } + if a, ok := vals[1].([]byte); ok { + out.Address = a + } + } + return out + } + return err +} + +// extractRevertData pulls the raw revert payload from an RPC error returned +// by ContractBackend.CallContract. Returns ok=false if the error is not an +// RPC error or carries no data field. +func extractRevertData(err error) ([]byte, bool) { + type dataError interface{ ErrorData() interface{} } + var de dataError + if !errors.As(err, &de) { + return nil, false + } + return normalizeErrorData(de.ErrorData()) +} + +// normalizeErrorData accepts the various concrete types that go-ethereum's +// rpc.DataError implementers return for ErrorData(): a 0x-prefixed string +// (the common case for net/http JSON-RPC), or raw bytes (some wrapped or +// mock backends). hexutil.Bytes is reported as []byte via its underlying +// type, so the []byte branch catches it too. +func normalizeErrorData(v interface{}) ([]byte, bool) { + switch d := v.(type) { + case string: + raw, err := hex.DecodeString(strings.TrimPrefix(d, "0x")) + if err != nil { + return nil, false + } + return raw, true + case []byte: + // Already-decoded revert bytes — accept as-is. + return d, true + default: + return nil, false + } +} diff --git a/urerrors_test.go b/urerrors_test.go new file mode 100644 index 0000000..0b35d85 --- /dev/null +++ b/urerrors_test.go @@ -0,0 +1,164 @@ +// 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 ens + +import ( + "encoding/hex" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// fakeRPCErr is a stand-in for a go-ethereum RPC error with revert data. +// It implements the rpc.DataError interface that ContractBackend.CallContract +// surfaces on a contract revert. +type fakeRPCErr struct { + msg string + data string +} + +func (e *fakeRPCErr) Error() string { return e.msg } +func (e *fakeRPCErr) ErrorData() interface{} { return e.data } + +// build a revert payload by concatenating selector + abi-encoded body. +func revert(t *testing.T, sel [4]byte, args ...byte) string { + t.Helper() + out := append([]byte{}, sel[:]...) + out = append(out, args...) + return "0x" + hex.EncodeToString(out) +} + +func TestTranslateURRevert_ResolverNotFound(t *testing.T) { + // ResolverNotFound(bytes name) with name = 0x07example03eth00. + body := mustPackArgs(t, argsResolverNotFound, []byte{0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0x03, 'e', 't', 'h', 0x00}) + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selResolverNotFound, body...)} + + got := translateURRevert(rpcErr) + var typed *ResolverNotFoundError + require.True(t, errors.As(got, &typed), "expected ResolverNotFoundError, got %T: %v", got, got) + require.True(t, errors.Is(got, ErrUnregistered)) + require.Equal(t, "unregistered name", got.Error()) + require.NotEmpty(t, typed.DNSName) +} + +func TestTranslateURRevert_EmptyAddress(t *testing.T) { + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selEmptyAddress)} + + got := translateURRevert(rpcErr) + var typed *EmptyAddressError + require.True(t, errors.As(got, &typed)) + require.True(t, errors.Is(got, ErrNoAddress)) +} + +func TestTranslateURRevert_UnsupportedProfile(t *testing.T) { + body := mustPackArgs(t, argsUnsupportedResolverProfile, [4]byte{0x3b, 0x3b, 0x57, 0xde}) + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selUnsupportedResolverProfile, body...)} + + got := translateURRevert(rpcErr) + var typed *UnsupportedResolverProfileError + require.True(t, errors.As(got, &typed)) + require.Equal(t, [4]byte{0x3b, 0x3b, 0x57, 0xde}, typed.Selector) +} + +func TestTranslateURRevert_ResolverNotContract(t *testing.T) { + dnsName := []byte{0x03, 'e', 't', 'h', 0x00} + addr := common.HexToAddress("0x000000000000000000000000000000000000dEaD") + body := mustPackArgs(t, argsResolverNotContract, dnsName, addr) + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selResolverNotContract, body...)} + + got := translateURRevert(rpcErr) + var typed *ResolverNotContractError + require.True(t, errors.As(got, &typed)) + require.Equal(t, addr, typed.Resolver) + require.Equal(t, dnsName, typed.DNSName) +} + +func TestTranslateURRevert_ResolverError(t *testing.T) { + inner := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02} + body := mustPackArgs(t, argsResolverError, inner) + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selResolverError, body...)} + + got := translateURRevert(rpcErr) + var typed *ResolverRevertError + require.True(t, errors.As(got, &typed), "expected ResolverRevertError, got %T: %v", got, got) + require.Equal(t, inner, typed.Data) + require.Contains(t, got.Error(), "resolver reverted: 0xdeadbeef0102") +} + +func TestTranslateURRevert_HTTPGateway(t *testing.T) { + body := mustPackArgs(t, argsHTTPError, uint16(503), "gateway temporarily unavailable") + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selHTTPError, body...)} + + got := translateURRevert(rpcErr) + var typed *HTTPGatewayError + require.True(t, errors.As(got, &typed), "expected HTTPGatewayError, got %T: %v", got, got) + require.Equal(t, uint16(503), typed.Status) + require.Equal(t, "gateway temporarily unavailable", typed.Message) + require.Contains(t, got.Error(), "503") + require.Contains(t, got.Error(), "gateway temporarily unavailable") +} + +func TestTranslateURRevert_ReverseAddressMismatch(t *testing.T) { + name := "alice.eth" + addrBytes := common.HexToAddress("0x000000000000000000000000000000000000bEEF").Bytes() + body := mustPackArgs(t, argsReverseAddressMismatch, name, addrBytes) + rpcErr := &fakeRPCErr{msg: "execution reverted", data: revert(t, selReverseAddressMismatch, body...)} + + got := translateURRevert(rpcErr) + var typed *ReverseAddressMismatchError + require.True(t, errors.As(got, &typed), "expected ReverseAddressMismatchError, got %T: %v", got, got) + require.Equal(t, name, typed.Name) + require.Equal(t, addrBytes, typed.Address) + require.Contains(t, got.Error(), name) +} + +func TestTranslateURRevert_UnknownPassThrough(t *testing.T) { + // A revert that is not a known UR custom error must pass through unchanged. + rpcErr := &fakeRPCErr{msg: "execution reverted", data: "0xdeadbeef"} + got := translateURRevert(rpcErr) + require.Same(t, rpcErr, got, "unknown selectors must not be translated") +} + +// rawDataRPCErr mirrors backends (e.g. mocks, custom RPC wrappers) that +// surface ErrorData() as already-decoded bytes rather than a hex string. +type rawDataRPCErr struct { + msg string + data []byte +} + +func (e *rawDataRPCErr) Error() string { return e.msg } +func (e *rawDataRPCErr) ErrorData() interface{} { return e.data } + +func TestTranslateURRevert_AcceptsByteSliceErrorData(t *testing.T) { + // Build the same EmptyAddress revert but expose it as []byte, not string. + revertBytes := append([]byte{}, selEmptyAddress[:]...) + rpcErr := &rawDataRPCErr{msg: "execution reverted", data: revertBytes} + + got := translateURRevert(rpcErr) + var typed *EmptyAddressError + require.True(t, errors.As(got, &typed), "expected EmptyAddressError, got %T: %v", got, got) + require.True(t, errors.Is(got, ErrNoAddress)) +} + +func TestTranslateURRevert_NonRPCError(t *testing.T) { + plain := errors.New("boom") + require.Same(t, plain, translateURRevert(plain), "non-RPC errors must pass through") + require.Nil(t, translateURRevert(nil)) +} + +// mustPackArgs is a tiny test helper: pack args with the given Arguments +// definition or fail the test. +func mustPackArgs(t *testing.T, args interface{ Pack(...interface{}) ([]byte, error) }, vals ...interface{}) []byte { + t.Helper() + out, err := args.Pack(vals...) + require.NoError(t, err) + return out +}