From eb7d7da8504a7e3652f4eff5770fbd97d1ca86b3 Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Thu, 10 Sep 2026 19:54:43 +0200 Subject: [PATCH 1/2] Add contract decoding submissions API SubmitContracts wraps POST /v1/contracts/decode and ListContractSubmissions wraps GET /v1/contracts/submissions, so contracts can be submitted for decoding in batches and their status tracked, mirroring the Contract Decoding section of the API docs. --- README.md | 35 ++++++++++ dune/contracts.go | 78 +++++++++++++++++++++ dune/contracts_test.go | 150 +++++++++++++++++++++++++++++++++++++++++ dune/dune.go | 8 +++ e2e/contracts_test.go | 49 ++++++++++++++ models/contracts.go | 136 +++++++++++++++++++++++++++++++++++++ 6 files changed, 456 insertions(+) create mode 100644 dune/contracts.go create mode 100644 dune/contracts_test.go create mode 100644 e2e/contracts_test.go create mode 100644 models/contracts.go diff --git a/README.md b/README.md index 36617d7..f30482f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,41 @@ for _, col := range dataset.Columns { } ``` +### Contract Decoding APIs + +Submit contracts for decoding in batches and track their status. Submissions are attributed to the user who created the API key; see the [docs](https://docs.dune.com/api-reference/contracts/introduction) for plan requirements. + +```go +key := "uniswap-token/ethereum/1" // optional, makes retries safe +resp, err := client.SubmitContracts(models.SubmitContractsRequest{ + Submissions: []models.ContractSubmissionInput{{ + BlockchainName: "ethereum", + Address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + ProjectName: "uniswap", + ContractName: "UniswapToken", + ABI: abiJSON, // json.RawMessage: the ABI array, or a JSON string containing it + IdempotencyKey: &key, + }}, +}) +if err != nil { + log.Fatal(err) +} +// One result per submission, matched by index. +for _, result := range resp.Results { + if result.Error != nil { + fmt.Printf("#%d failed: %s\n", result.Index, *result.Error) + continue + } + fmt.Printf("#%d queued as %s (%s)\n", result.Index, *result.SubmissionID, *result.Status) +} + +// Track status; pass page.NextCursor back as Cursor for the next page. +page, err := client.ListContractSubmissions(models.ListContractSubmissionsOptions{ + Status: "pending", + Limit: 20, +}) +``` + ### Table Management APIs The client provides comprehensive methods for managing uploaded tables: diff --git a/dune/contracts.go b/dune/contracts.go new file mode 100644 index 0000000..4d1e306 --- /dev/null +++ b/dune/contracts.go @@ -0,0 +1,78 @@ +package dune + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/duneanalytics/duneapi-client-go/models" +) + +// SubmitContracts submits up to 100 contracts for decoding in one request. +// Each item is validated and queued independently; the response carries one result +// per item, matched by index, so one bad ABI does not fail the batch. Submissions are +// attributed to the user who created the API key. +// https://docs.dune.com/api-reference/contracts/endpoint/decode +func (c *duneClient) SubmitContracts(req models.SubmitContractsRequest) (*models.SubmitContractsResponse, error) { + submitURL := fmt.Sprintf(submitContractsURLTemplate, c.env.Host) + + jsonData, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequest("POST", submitURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := httpRequest(c.env, httpReq) + if err != nil { + return nil, err + } + + var submitResp models.SubmitContractsResponse + if err := decodeBody(resp, &submitResp); err != nil { + return nil, err + } + if err := submitResp.HasError(); err != nil { + return nil, err + } + + return &submitResp, nil +} + +// ListContractSubmissions lists the contract decoding submissions made by the user +// who created the API key, newest first. Pass NextCursor from a response back as +// Cursor to fetch the next page. +// https://docs.dune.com/api-reference/contracts/endpoint/list +func (c *duneClient) ListContractSubmissions( + opts models.ListContractSubmissionsOptions, +) (*models.ListContractSubmissionsResponse, error) { + listURL := fmt.Sprintf(listContractSubmissionsURLTemplate, c.env.Host) + if query := opts.ToURLValues().Encode(); query != "" { + listURL += "?" + query + } + + req, err := http.NewRequest("GET", listURL, nil) + if err != nil { + return nil, err + } + + resp, err := httpRequest(c.env, req) + if err != nil { + return nil, err + } + + var listResp models.ListContractSubmissionsResponse + if err := decodeBody(resp, &listResp); err != nil { + return nil, err + } + if err := listResp.HasError(); err != nil { + return nil, err + } + + return &listResp, nil +} diff --git a/dune/contracts_test.go b/dune/contracts_test.go new file mode 100644 index 0000000..cba55b3 --- /dev/null +++ b/dune/contracts_test.go @@ -0,0 +1,150 @@ +package dune + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/stretchr/testify/require" +) + +func TestSubmitContracts(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]any + + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"results":[ + {"index":0,"submission_id":"sub_1","status":"pending"}, + {"index":1,"submission_id":"sub_0","status":"pending","replayed":true}, + {"index":2,"error":"abi must be valid JSON"} + ]}`) + }) + + upgrade := "upgrade" + reason := "new implementation" + key := "k/0" + resp, err := client.SubmitContracts(models.SubmitContractsRequest{ + Submissions: []models.ContractSubmissionInput{ + { + BlockchainName: "ethereum", + Address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + ProjectName: "uniswap", + ContractName: "UniswapToken", + ABI: json.RawMessage(`[{"type":"event","name":"Transfer","inputs":[]}]`), + IsProxy: true, + IdempotencyKey: &key, + }, + { + BlockchainName: "ethereum", + Address: "0x1", + ProjectName: "uniswap", + ContractName: "Old", + ABI: json.RawMessage(`"[]"`), + SubmissionType: &upgrade, + ResubmissionReason: &reason, + }, + }, + }) + + require.NoError(t, err) + require.Equal(t, "POST", gotMethod) + require.Equal(t, "/api/v1/contracts/decode", gotPath) + + submissions := gotBody["submissions"].([]any) + require.Len(t, submissions, 2) + first := submissions[0].(map[string]any) + require.Equal(t, []any{map[string]any{"type": "event", "name": "Transfer", "inputs": []any{}}}, first["abi"]) + require.Equal(t, true, first["is_proxy"]) + require.Equal(t, false, first["has_multiple_instances"]) + require.Equal(t, "k/0", first["idempotency_key"]) + require.NotContains(t, first, "submission_type") + second := submissions[1].(map[string]any) + require.Equal(t, "[]", second["abi"]) + require.Equal(t, "upgrade", second["submission_type"]) + require.NotContains(t, second, "idempotency_key") + + require.Len(t, resp.Results, 3) + require.Equal(t, "sub_1", *resp.Results[0].SubmissionID) + require.Equal(t, "pending", *resp.Results[0].Status) + require.False(t, resp.Results[0].Replayed) + require.Nil(t, resp.Results[0].Error) + require.True(t, resp.Results[1].Replayed) + require.Equal(t, 2, resp.Results[2].Index) + require.Nil(t, resp.Results[2].SubmissionID) + require.Equal(t, "abi must be valid JSON", *resp.Results[2].Error) +} + +func TestSubmitContractsReturnsAPIError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"error":"Resubmissions and multi-chain batches require a paid team plan."}`) + }) + + _, err := client.SubmitContracts(models.SubmitContractsRequest{ + Submissions: []models.ContractSubmissionInput{{BlockchainName: "base"}}, + }) + + require.ErrorIs(t, err, ErrorReqUnsuccessful) + require.ErrorContains(t, err, "[403]") + require.ErrorContains(t, err, "paid team plan") +} + +func TestListContractSubmissions(t *testing.T) { + var gotPath, gotQuery string + + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"submissions":[{ + "id":"sub_1","blockchain_name":"ethereum","address":"0x1", + "project_name":"p","contract_name":"c","status":"rejected","submission_type":"new", + "comment":"There is already a decoded contract at this address.", + "created_at":"2026-09-10T11:04:18.724658Z","updated_at":"2026-09-10T11:05:18Z", + "idempotency_key":"k/1" + }],"total":7,"next_cursor":"abc"}`) + }) + + resp, err := client.ListContractSubmissions(models.ListContractSubmissionsOptions{ + Limit: 1, + Cursor: "prev", + BlockchainName: "ethereum", + Status: "rejected", + }) + + require.NoError(t, err) + require.Equal(t, "/api/v1/contracts/submissions", gotPath) + require.Equal(t, "blockchain_name=ethereum&cursor=prev&limit=1&status=rejected", gotQuery) + require.Equal(t, 7, resp.Total) + require.Equal(t, "abc", *resp.NextCursor) + require.Len(t, resp.Submissions, 1) + require.Equal(t, "rejected", resp.Submissions[0].Status) + require.Equal(t, "There is already a decoded contract at this address.", *resp.Submissions[0].Comment) + require.Equal(t, "k/1", *resp.Submissions[0].IdempotencyKey) + require.Equal(t, 2026, resp.Submissions[0].CreatedAt.Year()) +} + +func TestListContractSubmissionsOmitsUnsetOptions(t *testing.T) { + var gotQuery string + + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"submissions":[],"total":0}`) + }) + + resp, err := client.ListContractSubmissions(models.ListContractSubmissionsOptions{}) + + require.NoError(t, err) + require.Equal(t, "", gotQuery) + require.Nil(t, resp.NextCursor) + require.Empty(t, resp.Submissions) +} diff --git a/dune/dune.go b/dune/dune.go index 7ec96f4..2b85e4d 100644 --- a/dune/dune.go +++ b/dune/dune.go @@ -132,6 +132,12 @@ type DuneClient interface { // WhoAmI returns the identity associated with the current API key WhoAmI() (*models.WhoAmIResponse, error) + // SubmitContracts submits up to 100 contracts for decoding and returns a per-item result + SubmitContracts(req models.SubmitContractsRequest) (*models.SubmitContractsResponse, error) + + // ListContractSubmissions lists the caller's contract decoding submissions, newest first + ListContractSubmissions(opts models.ListContractSubmissionsOptions) (*models.ListContractSubmissionsResponse, error) + // CreateVisualization creates a new visualization on an existing query CreateVisualization(req models.CreateVisualizationRequest) (*models.CreateVisualizationResponse, error) @@ -214,6 +220,8 @@ var ( searchDatasetsURLTemplate = "%s/api/v1/datasets/search" searchDatasetsByContractAddressURLTemplate = "%s/api/v1/datasets/search-by-contract" whoamiURLTemplate = "%s/api/whoami" + submitContractsURLTemplate = "%s/api/v1/contracts/decode" + listContractSubmissionsURLTemplate = "%s/api/v1/contracts/submissions" createVisualizationURLTemplate = "%s/api/v1/queries/%d/visualizations" visualizationURLTemplate = "%s/api/v1/visualizations/%d" listVisualizationsURLTemplate = "%s/api/v1/queries/%d/visualizations" diff --git a/e2e/contracts_test.go b/e2e/contracts_test.go new file mode 100644 index 0000000..c1c5959 --- /dev/null +++ b/e2e/contracts_test.go @@ -0,0 +1,49 @@ +package e2e + +import ( + "testing" + + "github.com/duneanalytics/duneapi-client-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Submitting is not exercised here: every SubmitContracts call creates a real +// decoding submission for the key's owner. +func TestListContractSubmissions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + client := setupClient(t) + + result, err := client.ListContractSubmissions(models.ListContractSubmissionsOptions{Limit: 5}) + require.NoError(t, err) + assert.NotNil(t, result.Submissions) + assert.GreaterOrEqual(t, result.Total, 0) + assert.LessOrEqual(t, len(result.Submissions), 5) + + for _, submission := range result.Submissions { + assert.NotEmpty(t, submission.ID) + assert.NotEmpty(t, submission.BlockchainName) + assert.NotEmpty(t, submission.Status) + } +} + +func TestListContractSubmissionsWithStatusFilter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + client := setupClient(t) + + result, err := client.ListContractSubmissions(models.ListContractSubmissionsOptions{ + Limit: 5, + Status: "rejected", + }) + require.NoError(t, err) + + for _, submission := range result.Submissions { + assert.Equal(t, "rejected", submission.Status) + } +} diff --git a/models/contracts.go b/models/contracts.go new file mode 100644 index 0000000..4d0eb18 --- /dev/null +++ b/models/contracts.go @@ -0,0 +1,136 @@ +package models + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "time" +) + +// ContractSubmissionInput is one contract to submit for decoding. It mirrors the +// form at https://dune.com/contracts/new. +type ContractSubmissionInput struct { + BlockchainName string `json:"blockchain_name"` + Address string `json:"address"` + ProjectName string `json:"project_name"` + ContractName string `json:"contract_name"` + // ABI is the contract ABI, either as its JSON array of fragments or as a JSON + // string containing that array. + ABI json.RawMessage `json:"abi"` + HasMultipleInstances bool `json:"has_multiple_instances"` + IsCreatedByFactory bool `json:"is_created_by_factory"` + IsManualABI bool `json:"is_manual_abi"` + IsProxy bool `json:"is_proxy"` + // SubmissionType is one of new (default), upgrade, rename, delete, other. + // Upgrades, renames, deletions and other always go to manual review. + SubmissionType *string `json:"submission_type,omitempty"` + // ResubmissionReason is required for delete and other. + ResubmissionReason *string `json:"resubmission_reason,omitempty"` + // OldProjectName and OldContractName are required for rename and name the + // contract as it is decoded today. + OldProjectName *string `json:"old_project_name,omitempty"` + OldContractName *string `json:"old_contract_name,omitempty"` + // IdempotencyKey is a client-chosen key, unique per account, that makes the + // item safe to retry: a key already used returns the existing submission. + IdempotencyKey *string `json:"idempotency_key,omitempty"` +} + +// SubmitContractsRequest is the body of POST /v1/contracts/decode. +type SubmitContractsRequest struct { + // Submissions holds between 1 and 100 contracts. + Submissions []ContractSubmissionInput `json:"submissions"` +} + +// ContractSubmissionResult is the per-item outcome of a submission batch, matched +// to the request by Index. On success SubmissionID and Status are set; on failure +// Error describes what to fix. +type ContractSubmissionResult struct { + Index int `json:"index"` + SubmissionID *string `json:"submission_id,omitempty"` + Status *string `json:"status,omitempty"` + Replayed bool `json:"replayed,omitempty"` + Error *string `json:"error,omitempty"` +} + +// SubmitContractsResponse is the response of POST /v1/contracts/decode. +type SubmitContractsResponse struct { + Results []ContractSubmissionResult `json:"results"` +} + +// HasError reports whether the response is missing its results array. +func (s SubmitContractsResponse) HasError() error { + if s.Results == nil { + return fmt.Errorf("missing results array") + } + return nil +} + +// ListContractSubmissionsOptions are the filters and paging for +// GET /v1/contracts/submissions. Zero values are omitted. +type ListContractSubmissionsOptions struct { + // Limit is the maximum number of submissions to return (default 50, max 250). + Limit int + // Cursor is next_cursor from a previous response, to fetch the next page. + Cursor string + BlockchainName string + Address string + ProjectName string + ContractName string + // Status is one of pending, approved, rejected, processed, in_progress, + // cancelled, needs_manual_review. + Status string +} + +// ToURLValues encodes the set options as query parameters. +func (o ListContractSubmissionsOptions) ToURLValues() url.Values { + values := url.Values{} + if o.Limit > 0 { + values.Set("limit", strconv.Itoa(o.Limit)) + } + for key, value := range map[string]string{ + "cursor": o.Cursor, + "blockchain_name": o.BlockchainName, + "address": o.Address, + "project_name": o.ProjectName, + "contract_name": o.ContractName, + "status": o.Status, + } { + if value != "" { + values.Set(key, value) + } + } + return values +} + +// ContractSubmission is a contract decoding submission as returned by +// GET /v1/contracts/submissions. +type ContractSubmission struct { + ID string `json:"id"` + BlockchainName string `json:"blockchain_name"` + Address string `json:"address"` + ProjectName string `json:"project_name"` + ContractName string `json:"contract_name"` + Status string `json:"status"` + SubmissionType string `json:"submission_type"` + Comment *string `json:"comment,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IdempotencyKey *string `json:"idempotency_key,omitempty"` +} + +// ListContractSubmissionsResponse is the response of GET /v1/contracts/submissions. +type ListContractSubmissionsResponse struct { + Submissions []ContractSubmission `json:"submissions"` + Total int `json:"total"` + // NextCursor is present when more results exist; pass it back as Cursor. + NextCursor *string `json:"next_cursor,omitempty"` +} + +// HasError reports whether the response is missing its submissions array. +func (l ListContractSubmissionsResponse) HasError() error { + if l.Submissions == nil { + return fmt.Errorf("missing submissions array") + } + return nil +} From 1aca9df06adc9d3c296f81173434ad549948bfe1 Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Fri, 11 Sep 2026 08:54:35 +0200 Subject: [PATCH 2/2] docs: complete contract submissions example --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index f30482f..8f428f4 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,15 @@ page, err := client.ListContractSubmissions(models.ListContractSubmissionsOption Status: "pending", Limit: 20, }) +if err != nil { + log.Fatal(err) +} +for _, submission := range page.Submissions { + fmt.Printf("%s: %s\n", submission.ID, submission.Status) +} +if page.NextCursor != nil { + fmt.Printf("next cursor: %s\n", *page.NextCursor) +} ``` ### Table Management APIs