Skip to content
Merged
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,50 @@ 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{
Comment thread
bh2smith marked this conversation as resolved.
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

The client provides comprehensive methods for managing uploaded tables:
Expand Down
78 changes: 78 additions & 0 deletions dune/contracts.go
Original file line number Diff line number Diff line change
@@ -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
}
150 changes: 150 additions & 0 deletions dune/contracts_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions dune/dune.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"
Expand Down
49 changes: 49 additions & 0 deletions e2e/contracts_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading