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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/bee/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
123 changes: 113 additions & 10 deletions pkg/resolver/client/ens/ens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions pkg/resolver/client/ens/ens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 &ethclient.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)
}
})
}
}
18 changes: 18 additions & 0 deletions pkg/resolver/client/ens/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}
Loading