Skip to content

Commit

Permalink
lanyard/client: add Golang API client for lanyard (#58)
Browse files Browse the repository at this point in the history
Co-authored-by: Ryan Smith <[email protected]>
  • Loading branch information
worm-emoji and ryandotsmith authored Aug 30, 2022
1 parent 76c9695 commit 4aa5c30
Show file tree
Hide file tree
Showing 7 changed files with 430 additions and 9 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
274 changes: 274 additions & 0 deletions clients/go/lanyard/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

1 comment on commit 4aa5c30

@vercel
Copy link

@vercel vercel bot commented on 4aa5c30 Aug 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

lanyard – ./

lanyard.context.wtf
lanyard-git-main.context.wtf
allowlist.context.wtf

Please sign in to comment.