diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..d911085 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,34 @@ +name: tests + +on: + push: + branches: [main] + paths: + - "cmd/api/**" + - "api/**" + - ".github/**" + - "**.go" + pull_request: + paths: + - "clients/**" + +jobs: + build: + name: integration + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: "1.19" + - uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + - name: test + run: go test ./... --tags=integration diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d75fa7..524d2da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,12 +4,12 @@ on: [pull_request] jobs: build: - name: all + name: packages runs-on: ubuntu-latest steps: - uses: actions/setup-go@v3 with: - go-version: '1.19' + go-version: "1.19" - uses: actions/cache@v3 with: path: | diff --git a/clients/go/lanyard/client.go b/clients/go/lanyard/client.go new file mode 100644 index 0000000..c2dca44 --- /dev/null +++ b/clients/go/lanyard/client.go @@ -0,0 +1,274 @@ +// An API client for [lanyard.org]. +// +// [lanyard.org]: https://lanyard.org +package lanyard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "golang.org/x/xerrors" +) + +var ErrNotFound error = xerrors.New("resource not found") + +type Client struct { + httpClient *http.Client + url string +} + +type ClientOpt func(*Client) + +func WithURL(url string) ClientOpt { + return func(c *Client) { + c.url = url + } +} + +func WithClient(hc *http.Client) ClientOpt { + return func(c *Client) { + c.httpClient = hc + } +} + +// Uses https://lanyard.org/api/v1 for a default url +// and http.Client with a 30s timeout unless specified +// using [WithURL] or [WithClient] +func New(opts ...ClientOpt) *Client { + const url = "https://lanyard.org/api/v1" + c := &Client{ + url: url, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *Client) sendRequest( + ctx context.Context, + method, path string, + body, destination any, +) error { + var ( + url string = c.url + path + jsonb []byte + err error + ) + + if body != nil { + jsonb, err = json.Marshal(body) + if err != nil { + return err + } + } + + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(jsonb)) + if err != nil { + return xerrors.Errorf("error creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "lanyard-go+v1.0.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return xerrors.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode >= 400 { + // special case 404s to make consuming client API easier + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + + return xerrors.Errorf("error making http request: %s", resp.Status) + } + + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&destination); err != nil { + return xerrors.Errorf("failed to decode response: %w", err) + } + + return nil +} + +type createTreeRequest struct { + UnhashedLeaves []hexutil.Bytes `json:"unhashedLeaves"` + LeafTypeDescriptor []string `json:"leafTypeDescriptor,omitempty"` + PackedEncoding bool `json:"packedEncoding"` +} + +type CreateResponse struct { + // MerkleRoot is the root of the created merkle tree + MerkleRoot hexutil.Bytes `json:"merkleRoot"` +} + +// If you have a list of addresses for an allowlist, you can +// create a Merkle tree using CreateTree. Any Merkle tree +// published on Lanyard will be publicly available to any +// user of Lanyard’s API, including minting interfaces such +// as Zora or mint.fun. +func (c *Client) CreateTree( + ctx context.Context, + addresses []hexutil.Bytes, +) (*CreateResponse, error) { + req := &createTreeRequest{ + UnhashedLeaves: addresses, + PackedEncoding: true, + } + + resp := &CreateResponse{} + err := c.sendRequest(ctx, http.MethodPost, "/tree", req, resp) + if err != nil { + return nil, err + } + + return resp, nil +} + +// CreateTypedTree is a more advanced way of creating a tree. +// Useful if your tree has ABI encoded data, such as quantity +// or other values. +// unhashedLeaves is a slice of addresses or ABI encoded types. +// leafTypeDescriptor describes the abi-encoded types of the leaves, and +// is required if leaves are not address types. +// Set packedEncoding to true if your arguments are packed/encoded +func (c *Client) CreateTypedTree( + ctx context.Context, + unhashedLeaves []hexutil.Bytes, + leafTypeDescriptor []string, + packedEncoding bool, +) (*CreateResponse, error) { + req := &createTreeRequest{ + UnhashedLeaves: unhashedLeaves, + LeafTypeDescriptor: leafTypeDescriptor, + PackedEncoding: packedEncoding, + } + + resp := &CreateResponse{} + + err := c.sendRequest(ctx, http.MethodPost, "/tree", req, resp) + if err != nil { + return nil, err + } + + return resp, nil +} + +type TreeResponse struct { + // UnhashedLeaves is a slice of addresses or ABI encoded types + UnhashedLeaves []hexutil.Bytes `json:"unhashedLeaves"` + + // LeafTypeDescriptor describes the abi-encoded types of the leaves, and + // is required if leaves are not address types + LeafTypeDescriptor []string `json:"leafTypeDescriptor"` + + // PackedEncoding is true by default + PackedEncoding bool `json:"packedEncoding"` + + LeafCount int `json:"leafCount"` +} + +// If a Merkle tree has been published to Lanyard, GetTreeFromRoot +// will return the entire tree based on the root. +// This endpoint will return ErrNotFound if the tree +// associated with the root has not been published. +func (c *Client) GetTreeFromRoot( + ctx context.Context, + root hexutil.Bytes, +) (*TreeResponse, error) { + resp := &TreeResponse{} + + err := c.sendRequest( + ctx, http.MethodGet, + fmt.Sprintf("/tree?root=%s", root.String()), + nil, resp, + ) + + if err != nil { + return nil, err + } + + return resp, nil +} + +type ProofResponse struct { + UnhashedLeaf hexutil.Bytes `json:"unhashedLeaf"` + Proof []hexutil.Bytes `json:"proof"` +} + +// If the tree has been published to Lanyard, GetProof will +// return the proof associated with an unHashedLeaf. +// This endpoint will return ErrNotFound if the tree +// associated with the root has not been published. +func (c *Client) GetProofFromLeaf( + ctx context.Context, + root hexutil.Bytes, + unhashedLeaf hexutil.Bytes, +) (*ProofResponse, error) { + resp := &ProofResponse{} + + err := c.sendRequest( + ctx, http.MethodGet, + fmt.Sprintf("/proof?root=%s&unhashedLeaf=%s", + root.String(), unhashedLeaf.String(), + ), + nil, resp, + ) + + if err != nil { + return nil, err + } + + return resp, nil +} + +type RootResponse struct { + Root hexutil.Bytes `json:"root"` +} + +// If a Merkle tree has been published to Lanyard, +// GetRootFromLeaf will return the root of the tree +// based on a proof of a leaf. This endpoint will return +// ErrNotFound if the tree associated with the +// leaf has not been published. +func (c *Client) GetRootFromProof( + ctx context.Context, + proof []hexutil.Bytes, +) (*RootResponse, error) { + resp := &RootResponse{} + + if len(proof) == 0 { + return nil, xerrors.New("proof must not be empty") + } + + var pq []string + for _, p := range proof { + pq = append(pq, p.String()) + } + + err := c.sendRequest( + ctx, http.MethodGet, + fmt.Sprintf("/root?proof=%s", + strings.Join(pq, ","), + ), + nil, resp, + ) + + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/clients/go/lanyard/client_integration_test.go b/clients/go/lanyard/client_integration_test.go new file mode 100644 index 0000000..424b50d --- /dev/null +++ b/clients/go/lanyard/client_integration_test.go @@ -0,0 +1,113 @@ +//go:build integration + +package lanyard + +import ( + "context" + "os" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" +) + +var ( + client *Client + basicMerkle = []hexutil.Bytes{ + hexutil.MustDecode("0x0000000000000000000000000000000000000001"), + hexutil.MustDecode("0x0000000000000000000000000000000000000002"), + hexutil.MustDecode("0x0000000000000000000000000000000000000003"), + hexutil.MustDecode("0x0000000000000000000000000000000000000004"), + hexutil.MustDecode("0x0000000000000000000000000000000000000005"), + } +) + +func init() { + if os.Getenv("LANYARD_API_BASE_URL") == "" { + client = New() + } else { + client = New(WithURL(os.Getenv("LANYARD_API_BASE_URL"))) + } +} + +const basicRoot = "0xa7a6b1cb6d12308ec4818baac3413fafa9e8b52cdcd79252fa9e29c9a2f8aff1" + +func TestBasicMerkleTree(t *testing.T) { + tree, err := client.CreateTree(context.Background(), basicMerkle) + if err != nil { + t.Fatal(err) + } + + if tree.MerkleRoot.String() != basicRoot { + t.Fatalf("expected %s, got %s", basicRoot, tree.MerkleRoot.String()) + } +} + +func TestCreateTypedTree(t *testing.T) { + tree, err := client.CreateTypedTree( + context.Background(), + []hexutil.Bytes{ + hexutil.MustDecode("0x00000000000000000000000000000000000000010000000000000000000000000000000000000000000000008ac7230489e80000"), + hexutil.MustDecode("0x0000000000000000000000000000000000000002000000000000000000000000000000000000000000000001e5b8fa8fe2ac0000"), + }, + []string{"address", "uint256"}, + true, + ) + + if err != nil { + t.Fatal(err) + } + + const typedRoot = "0x6306f03ad6ae2ffeca080333a0a6828669192f5f8b61f70738bfe8ceb7e0a434" + if tree.MerkleRoot.String() != typedRoot { + t.Fatalf("expected %s, got %s", typedRoot, tree.MerkleRoot.String()) + } +} + +func TestBasicMerkleProof(t *testing.T) { + _, err := client.GetProofFromLeaf(context.Background(), hexutil.MustDecode(basicRoot), basicMerkle[0]) + if err != nil { + t.Fatal(err) + } +} + +func TestBasicMerkleProof404(t *testing.T) { + _, err := client.GetProofFromLeaf(context.Background(), []byte{0x01}, hexutil.MustDecode("0x0000000000000000000000000000000000000001")) + if err != ErrNotFound { + t.Fatal("expected custom 404 err type for invalid request, got %w", err) + } +} + +func TestGetRootFromProof(t *testing.T) { + p, err := client.GetProofFromLeaf(context.Background(), hexutil.MustDecode(basicRoot), basicMerkle[0]) + + if err != nil { + t.Fatal(err) + } + + root, err := client.GetRootFromProof(context.Background(), p.Proof) + + if err != nil { + t.Fatal(err) + } + + if root.Root.String() != basicRoot { + t.Fatalf("expected %s, got %s", basicRoot, root.Root.String()) + } + +} + +func TestGetTree(t *testing.T) { + tree, err := client.GetTreeFromRoot(context.Background(), hexutil.MustDecode(basicRoot)) + + if err != nil { + t.Fatal(err) + } + + if tree.UnhashedLeaves[0].String() != basicMerkle[0].String() { + t.Fatalf("expected %s, got %s", basicMerkle[0].String(), tree.UnhashedLeaves[0].String()) + } + + if tree.LeafCount != len(basicMerkle) { + t.Fatalf("expected %d, got %d", len(basicMerkle), tree.LeafCount) + } +} diff --git a/go.mod b/go.mod index 8cf3028..fd26a49 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/opentracing/opentracing-go v1.2.0 github.com/rs/cors v1.8.2 github.com/rs/zerolog v1.27.0 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df gopkg.in/DataDog/dd-trace-go.v1 v1.40.1 ) @@ -46,7 +46,7 @@ require ( github.com/tinylib/msgp v1.1.2 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect google.golang.org/protobuf v1.27.1 // indirect diff --git a/go.sum b/go.sum index 6664a89..9b15292 100644 --- a/go.sum +++ b/go.sum @@ -775,8 +775,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -841,8 +841,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/www/components/Docs/index.tsx b/www/components/Docs/index.tsx index 5720872..4fd7755 100644 --- a/www/components/Docs/index.tsx +++ b/www/components/Docs/index.tsx @@ -25,7 +25,7 @@ export default function Docs() {