From de74156fa2088415bce3120f74a80124125263eb Mon Sep 17 00:00:00 2001 From: Peter Foldiak Date: Sun, 6 Sep 2026 00:01:18 +0400 Subject: [PATCH] feat(resolver): resolve names on contracts that serve the ENS resolver profile without a registry Some ENS-compatible name services (for example the Gwei Name Service, .gwei) have no registry: the name contract itself implements the ENS resolver profile (contenthash, addr, text on EIP-137 name hashes) and advertises it through EIP-165. The ENS client required the configured contract to be a registry (owner, then resolver, then contenthash) and failed on dial. When the registry probe fails, probe supportsInterface for the contenthash profile (0xbc1c58d1); if the contract answers true, keep the connection with no registry and resolve names by reading contenthash from the contract directly (goens.NewResolverAt). An empty record is reported as not found. Existing registry behaviour is unchanged. Closes #5600 --- cmd/bee/cmd/cmd.go | 2 +- pkg/resolver/client/ens/ens.go | 123 ++++++++++++++++++++-- pkg/resolver/client/ens/ens_test.go | 135 +++++++++++++++++++++++++ pkg/resolver/client/ens/export_test.go | 18 ++++ 4 files changed, 267 insertions(+), 11 deletions(-) diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go index 658012fabce..e72a142e0ea 100644 --- a/cmd/bee/cmd/cmd.go +++ b/cmd/bee/cmd/cmd.go @@ -347,7 +347,7 @@ func (c *command) setAllFlags(cmd *cobra.Command) { cmd.Flags().String(optionNamePaymentThreshold, "13500000", "threshold in BZZ where you expect to get paid from your peers") cmd.Flags().Int64(optionNamePaymentTolerance, 25, "excess debt above payment threshold in percentages where you disconnect from your peer") cmd.Flags().Int64(optionNamePaymentEarly, 50, "percentage below the peers payment threshold when we initiate settlement") - cmd.Flags().StringSlice(optionNameResolverEndpoints, []string{}, "ENS compatible API endpoint for a TLD and with contract address, can be repeated, format [tld:][contract-addr@]url") + cmd.Flags().StringSlice(optionNameResolverEndpoints, []string{}, "ENS compatible API endpoint for a TLD and with contract address, can be repeated, format [tld:][contract-addr@]url; the contract may be an ENS registry or a contract that serves the ENS resolver profile (contenthash) directly") cmd.Flags().Bool(optionNameBootnodeMode, false, "cause the node to always accept incoming connections") cmd.Flags().String(optionNameBlockchainRpcEndpoint, "", "rpc blockchain endpoint") cmd.Flags().Duration(optionNameBlockchainRpcDialTimeout, 30*time.Second, "blockchain rpc TCP dial timeout") diff --git a/pkg/resolver/client/ens/ens.go b/pkg/resolver/client/ens/ens.go index 3a6ad83e5cc..05875985cf9 100644 --- a/pkg/resolver/client/ens/ens.go +++ b/pkg/resolver/client/ens/ens.go @@ -6,10 +6,14 @@ package ens import ( "bytes" + "context" "errors" "fmt" + "math/big" "strings" + "time" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" goens "github.com/wealdtech/go-ens/v3" @@ -22,6 +26,18 @@ import ( const ( defaultENSContractAddress = "00000000000C2E074eC69A0dFb2997BA6C7d2e1e" swarmContentHashPrefix = "bzz://" + + // probeTimeout bounds the extra call made when the configured contract is + // not an ENS registry. + probeTimeout = 10 * time.Second +) + +var ( + // supportsInterfaceSelector is the EIP-165 supportsInterface(bytes4) selector. + supportsInterfaceSelector = []byte{0x01, 0xff, 0xc9, 0xa7} + // contenthashInterfaceID is the EIP-165 id of the ENS contenthash resolver + // profile (ENSIP-7), contenthash(bytes32). + contenthashInterfaceID = []byte{0xbc, 0x1c, 0x58, 0xd1} ) // Address is the swarm bzz address. @@ -44,13 +60,23 @@ var ( // Client is a name resolution client that can connect to ENS via an // Ethereum endpoint. +// +// The configured contract is normally an ENS registry. It may instead be a +// contract that implements the ENS resolver profile itself (contenthash, +// addr, text on EIP-137 name hashes) without a registry in front, as some +// ENS-compatible name services do; such a contract is detected when it is +// dialled, and names are then resolved by asking it for the content hash +// directly. type Client struct { - endpoint string - contractAddr string - ethCl *ethclient.Client - connectFn func(string, string) (*ethclient.Client, *goens.Registry, error) - resolveFn func(*goens.Registry, common.Address, string) (string, error) - registry *goens.Registry + endpoint string + contractAddr string + ethCl *ethclient.Client + connectFn func(string, string) (*ethclient.Client, *goens.Registry, error) + resolveFn func(*goens.Registry, common.Address, string) (string, error) + resolveDirectFn func(*ethclient.Client, common.Address, string) (string, error) + registry *goens.Registry + // direct is set when the contract is a resolver rather than a registry. + direct bool } // Option is a function that applies an option to a Client. @@ -59,9 +85,10 @@ type Option func(*Client) // NewClient will return a new Client. func NewClient(endpoint string, opts ...Option) (client.Interface, error) { c := &Client{ - endpoint: endpoint, - connectFn: wrapDial, - resolveFn: wrapResolve, + endpoint: endpoint, + connectFn: wrapDial, + resolveFn: wrapResolve, + resolveDirectFn: wrapResolveDirect, } // Apply all options to the Client. @@ -84,6 +111,9 @@ func NewClient(endpoint string, opts ...Option) (client.Interface, error) { } c.ethCl = ethCl c.registry = registry + // A live connection without a registry means the contract answered the + // resolver-profile probe in the dial function. + c.direct = registry == nil && ethCl != nil return c, nil } @@ -112,7 +142,16 @@ func (c *Client) Resolve(name string) (Address, error) { return swarm.ZeroAddress, fmt.Errorf("resolveFn: %w", ErrNotImplemented) } - hash, err := c.resolveFn(c.registry, common.HexToAddress(c.contractAddr), name) + var hash string + var err error + if c.direct { + if c.resolveDirectFn == nil { + return swarm.ZeroAddress, fmt.Errorf("resolveDirectFn: %w", ErrNotImplemented) + } + hash, err = c.resolveDirectFn(c.ethCl, common.HexToAddress(c.contractAddr), name) + } else { + hash, err = c.resolveFn(c.registry, common.HexToAddress(c.contractAddr), name) + } if err != nil { return swarm.ZeroAddress, fmt.Errorf("%w: %w", err, ErrResolveFailed) } @@ -159,12 +198,76 @@ func wrapDial(endpoint, contractAddr string) (*ethclient.Client, *goens.Registry // Ensure that the ENS registry client is deployed to the given contract address. _, err = registry.Owner("") if err != nil { + // Not a registry. The contract may still be a resolver that serves the + // ENS resolver profile directly (no registry in front); in that case + // return a nil registry and resolve against the contract itself. + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + isResolver, probeErr := supportsContenthash(ctx, ethCl, common.HexToAddress(contractAddr)) + if probeErr == nil && isResolver { + return ethCl, nil, nil + } return nil, nil, fmt.Errorf("owner: %w", err) } return ethCl, registry, nil } +// contractCaller is the part of an Ethereum client needed for the EIP-165 +// probe; it lets tests use a fake instead of a live connection. +type contractCaller interface { + CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) +} + +// supportsContenthash reports whether the contract at addr answers true to +// EIP-165 supportsInterface for the ENS contenthash resolver profile. +func supportsContenthash(ctx context.Context, caller contractCaller, addr common.Address) (bool, error) { + // supportsInterface(bytes4): selector, then the 4-byte id left-aligned in + // a 32-byte word. + data := make([]byte, 0, 4+32) + data = append(data, supportsInterfaceSelector...) + data = append(data, contenthashInterfaceID...) + data = append(data, make([]byte, 28)...) + out, err := caller.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil) + if err != nil { + return false, err + } + // A bool return is a 32-byte word; anything else means the contract does + // not implement EIP-165 (or does not exist). + if len(out) != 32 { + return false, nil + } + return out[31] == 1, nil +} + +// wrapResolveDirect reads the content hash from a contract that implements +// the ENS resolver profile for the name itself, without a registry lookup. +// An unregistered name has no record and yields an empty content hash. +func wrapResolveDirect(ethCl *ethclient.Client, contractAddr common.Address, name string) (string, error) { + ensR, err := goens.NewResolverAt(ethCl, name, contractAddr) + if err != nil { + return "", fmt.Errorf("%w: %w", resolver.ErrServiceNotAvailable, err) + } + + ch, err := ensR.Contenthash() + if err != nil { + if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "rate limit") { + return "", fmt.Errorf("%w: %w", resolver.ErrServiceNotAvailable, err) + } + return "", fmt.Errorf("contenthash: %w: %w", err, resolver.ErrInvalidContentHash) + } + if len(ch) == 0 { + return "", fmt.Errorf("%w: %w", errNameNotRegistered, resolver.ErrNotFound) + } + + addr, err := goens.ContenthashToString(ch) + if err != nil { + return "", fmt.Errorf("contenthash to string: %w: %w", err, resolver.ErrInvalidContentHash) + } + + return addr, nil +} + func wrapResolve(registry *goens.Registry, _ common.Address, name string) (string, error) { ownerAddress, err := registry.Owner(name) // it returns error only if the service is not available diff --git a/pkg/resolver/client/ens/ens_test.go b/pkg/resolver/client/ens/ens_test.go index 77253d786ba..63d0421a6f9 100644 --- a/pkg/resolver/client/ens/ens_test.go +++ b/pkg/resolver/client/ens/ens_test.go @@ -5,9 +5,12 @@ package ens_test import ( + "context" "errors" + "math/big" "testing" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" @@ -208,3 +211,135 @@ func TestResolve(t *testing.T) { }) } } + +// TestResolveDirect covers the path taken when the configured contract is a +// resolver (it serves the ENS resolver profile itself) rather than a registry: +// the dial function returns a live client and no registry. +func TestResolveDirect(t *testing.T) { + t.Parallel() + + testContractAddrString := "9D51D507BC7264d4fE8Ad1cf7Fe191933A0a81d6" + testContractAddr := common.HexToAddress(testContractAddrString) + testSwarmAddr := swarm.MustParseHexAddress("aaabbbcc") + + testCases := []struct { + desc string + name string + resolveDirectFn func(*ethclient.Client, common.Address, string) (string, error) + wantErr error + wantAddr swarm.Address + }{ + { + desc: "nil direct resolve function", + resolveDirectFn: nil, + wantErr: ens.ErrNotImplemented, + }, + { + desc: "not registered", + resolveDirectFn: func(*ethclient.Client, common.Address, string) (string, error) { + return "", resolver.ErrNotFound + }, + wantErr: resolver.ErrNotFound, + }, + { + desc: "resolves against the configured contract", + name: "example.gwei", + resolveDirectFn: func(_ *ethclient.Client, c common.Address, name string) (string, error) { + if c != testContractAddr { + return "", errors.New("invalid contract address") + } + if name != "example.gwei" { + return "", errors.New("invalid name") + } + return ens.SwarmContentHashPrefix + testSwarmAddr.String(), nil + }, + wantAddr: testSwarmAddr, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + t.Parallel() + + cl, err := ens.NewClient("example.com", + ens.WithContractAddress(testContractAddrString), + ens.WithConnectFunc(func(endpoint, contractAddr string) (*ethclient.Client, *goens.Registry, error) { + return ðclient.Client{}, nil, nil // connected, no registry: a resolver contract + }), + ens.WithResolveFunc(func(*goens.Registry, common.Address, string) (string, error) { + return "", errors.New("registry path must not be used for a resolver contract") + }), + ens.WithResolveDirectFunc(tC.resolveDirectFn), + ) + if err != nil { + t.Fatal(err) + } + got, err := cl.Resolve(tC.name) + if err != nil { + if !errors.Is(err, tC.wantErr) { + t.Errorf("got %v, want %v", err, tC.wantErr) + } + return + } + if tC.wantErr != nil { + t.Fatalf("got no error, want %v", tC.wantErr) + } + if !got.Equal(tC.wantAddr) { + t.Errorf("got %s, want %s", got, tC.wantAddr) + } + }) + } +} + +type fakeCaller struct { + out []byte + err error + gotCall ethereum.CallMsg +} + +func (f *fakeCaller) CallContract(_ context.Context, call ethereum.CallMsg, _ *big.Int) ([]byte, error) { + f.gotCall = call + return f.out, f.err +} + +func TestSupportsContenthash(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("9D51D507BC7264d4fE8Ad1cf7Fe191933A0a81d6") + word := func(last byte) []byte { b := make([]byte, 32); b[31] = last; return b } + + testCases := []struct { + desc string + out []byte + err error + want bool + wantErr bool + }{ + {desc: "supports the contenthash profile", out: word(1), want: true}, + {desc: "does not support it", out: word(0), want: false}, + {desc: "no EIP-165 (empty return)", out: nil, want: false}, + {desc: "call error", err: errors.New("rpc down"), wantErr: true}, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + t.Parallel() + + fc := &fakeCaller{out: tC.out, err: tC.err} + got, err := ens.SupportsContenthash(context.Background(), fc, addr) + if (err != nil) != tC.wantErr { + t.Fatalf("error: got %v, wantErr %v", err, tC.wantErr) + } + if got != tC.want { + t.Errorf("got %v, want %v", got, tC.want) + } + if fc.gotCall.To == nil || *fc.gotCall.To != addr { + t.Errorf("call target: got %v, want %v", fc.gotCall.To, addr) + } + // supportsInterface(bytes4) selector followed by the contenthash + // interface id in a 32-byte word. + wantData := append([]byte{0x01, 0xff, 0xc9, 0xa7, 0xbc, 0x1c, 0x58, 0xd1}, make([]byte, 28)...) + if string(fc.gotCall.Data) != string(wantData) { + t.Errorf("call data: got %x, want %x", fc.gotCall.Data, wantData) + } + }) + } +} diff --git a/pkg/resolver/client/ens/export_test.go b/pkg/resolver/client/ens/export_test.go index 2749e5484aa..b45191379af 100644 --- a/pkg/resolver/client/ens/export_test.go +++ b/pkg/resolver/client/ens/export_test.go @@ -5,6 +5,8 @@ package ens import ( + "context" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" goens "github.com/wealdtech/go-ens/v3" @@ -25,3 +27,19 @@ func WithResolveFunc(fn func(registry *goens.Registry, addr common.Address, inpu c.resolveFn = fn } } + +// WithResolveDirectFunc will set the direct (resolver-profile) Resolve +// function implementation. +func WithResolveDirectFunc(fn func(ethCl *ethclient.Client, addr common.Address, input string) (string, error)) Option { + return func(c *Client) { + c.resolveDirectFn = fn + } +} + +// ContractCaller is the subset of an Ethereum client used by the EIP-165 probe. +type ContractCaller = contractCaller + +// SupportsContenthash exposes the EIP-165 probe for testing. +func SupportsContenthash(ctx context.Context, caller ContractCaller, addr common.Address) (bool, error) { + return supportsContenthash(ctx, caller, addr) +}