diff --git a/bigmap.go b/bigmap.go new file mode 100644 index 0000000..89b88ef --- /dev/null +++ b/bigmap.go @@ -0,0 +1,118 @@ +package tzkt + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// GetBigMapValueByPointer returns the value of a key in a bigmap. +func (c *TZKT) GetBigMapValueByPointer(pointer int, key string) ([]byte, error) { + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: fmt.Sprintf("/v1/bigmaps/%d/keys", pointer), + RawQuery: url.Values{ + "select": []string{"value"}, + "key": []string{key}, + }.Encode(), + } + + var results []json.RawMessage + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + if err := c.request(req, &results); err != nil { + return nil, err + } + + if len(results) == 0 { + return nil, fmt.Errorf("error key not found") + } + + return results[0], nil +} + +// GetBigMapPointersByContract returns a list of big map pointer for a contract. +// This call accepts tags and an option. +func (c *TZKT) GetBigMapPointersByContract(contract string, tags ...string) ([]int, error) { + query := url.Values{ + "contract": []string{contract}, + "select": []string{"ptr"}, + } + + if len(tags) > 0 { + query["tags.any"] = []string{strings.Join(tags, ",")} + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/bigmaps", + RawQuery: query.Encode(), + } + + var pointer []int + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + if err := c.request(req, &pointer); err != nil { + return nil, err + } + + return pointer, nil +} + +// GetBigMapsByContractAndPath get BitMap of contract +func (c *TZKT) GetBigMapsByContractAndPath(contract string, path string) (int, error) { + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/bigmaps", + RawQuery: url.Values{ + "contract": []string{contract}, + "select": []string{"ptr"}, + "path": []string{path}, + }.Encode(), + } + + var pointer []int + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return 0, err + } + + if err := c.request(req, &pointer); err != nil { + return 0, err + } + + if len(pointer) == 0 { + return 0, fmt.Errorf("no pointer") + } + + return pointer[0], nil +} + +// GetBigMapPointerForContractTokenMetadata returns the bigmap pointer of token_metadata +// for a specific contract +func (c *TZKT) GetBigMapPointerForContractTokenMetadata(contract string) (int, error) { + pointers, err := c.GetBigMapPointersByContract(contract, "token_metadata") + if err != nil { + return 0, err + } + + if len(pointers) == 0 { + return 0, fmt.Errorf("no pointer") + } + + return pointers[0], nil +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..164fc62 --- /dev/null +++ b/client.go @@ -0,0 +1,251 @@ +package tzkt + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" +) + +var ErrTooManyRequest = fmt.Errorf("too many requests") + +type TZKT struct { + endpoint string + + client *http.Client +} + +type NullableInt int64 + +func New(network string) *TZKT { + endpoint := "api.mainnet.tzkt.io" + if network == "testnet" { + endpoint = "api.ghostnet.tzkt.io" + } + + return &TZKT{ + client: &http.Client{ + Timeout: time.Minute, + }, + endpoint: endpoint, + } +} + +type FormatDimensions struct { + Unit string `json:"unit"` + Value string `json:"value"` +} + +type FileFormat struct { + URI string `json:"uri"` + FileName string `json:"fileName,omitempty"` + FileSize int `json:"fileSize,string"` + MIMEType MIMEFormat `json:"mimeType"` + Dimensions FormatDimensions `json:"dimensions,omitempty"` +} + +type MIMEFormat string + +func (m *MIMEFormat) UnmarshalJSON(data []byte) error { + if data[0] == 91 { + data = bytes.Trim(data, "[]") + } + + return json.Unmarshal(data, (*string)(m)) +} + +type FileFormats []FileFormat + +func (t *NullableInt) UnmarshalJSON(data []byte) error { + var num int64 + + err := json.Unmarshal(data, &num) + if err != nil { + *t = NullableInt(-1) + + return nil + } + + *t = NullableInt(num) + + return nil +} + +func (f *FileFormats) UnmarshalJSON(data []byte) error { + type formats FileFormats + + switch data[0] { + case 34: + d1 := bytes.ReplaceAll(bytes.Trim(data, `"`), []byte{92, 117, 48, 48, 50, 50}, []byte{34}) + d := bytes.ReplaceAll(d1, []byte{92, 34}, []byte{34}) + + if err := json.Unmarshal(d, (*formats)(f)); err != nil { + return err + } + case 123: // If the "formats" is not an array + d := append([]byte{91}, data...) + if data[len(data)-1] != 93 { + d = append(d, []byte{93}...) + } + if err := json.Unmarshal(d, (*formats)(f)); err != nil { + return err + } + default: + if err := json.Unmarshal(data, (*formats)(f)); err != nil { + return err + } + } + + return nil +} + +type FileCreators []string + +func (c *FileCreators) UnmarshalJSON(data []byte) error { + type creators FileCreators + + switch data[0] { + case 34: + d1 := bytes.ReplaceAll(bytes.Trim(data, `"`), []byte{92, 117, 48, 48, 50, 50}, []byte{34}) + d := bytes.ReplaceAll(d1, []byte{92, 34}, []byte{34}) + + if err := json.Unmarshal(d, (*creators)(c)); err != nil { + return err + } + default: + if err := json.Unmarshal(data, (*creators)(c)); err != nil { + return err + } + } + + return nil +} + +type TokenID struct { + big.Int +} + +func (b TokenID) MarshalJSON() ([]byte, error) { + return []byte(b.String()), nil +} + +func (b *TokenID) UnmarshalJSON(p []byte) error { + s := string(p) + + if s == "null" { + return fmt.Errorf("invalid token id: %s", p) + } + + z, ok := big.NewInt(0).SetString(strings.Trim(s, `"`), 0) + if !ok { + return fmt.Errorf("invalid token id: %s", p) + } + + b.Int = *z + return nil +} + +type Account struct { + Alias string `json:"alias"` + Address string `json:"address"` +} + +type Token struct { + Contract Account `json:"contract"` + ID TokenID `json:"tokenId"` + Standard string `json:"standard"` + TotalSupply NullableInt `json:"totalSupply,string"` + Timestamp time.Time `json:"firstTime"` + Metadata *TokenMetadata `json:"metadata,omitempty"` +} + +type OwnedToken struct { + Token Token `json:"token"` + Balance NullableInt `json:"balance,string"` + LastTime time.Time `json:"lastTime"` +} + +type TokenMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + Symbol string `json:"symbol"` + RightURI string `json:"rightUri"` + ArtifactURI string `json:"artifactUri"` + DisplayURI string `json:"displayUri"` + ThumbnailURI string `json:"thumbnailUri"` + Publishers []string `json:"publishers"` + Creators FileCreators `json:"creators"` + Formats FileFormats `json:"formats"` + + ArtworkMetadata map[string]interface{} `json:"artworkMetadata"` +} + +type TokenTransfer struct { + Timestamp time.Time `json:"timestamp"` + Level uint64 `json:"level"` + TransactionID uint64 `json:"transactionId"` + From *Account `json:"from"` + To Account `json:"to"` +} + +type TokenOwner struct { + Address string `json:"address"` + Balance int64 `json:"balance,string"` + LastTime time.Time `json:"lastTime"` +} + +type TransactionDetails struct { + Block string `json:"block"` + Parameter TransactionParameter `json:"parameter"` + Target Account `json:"target"` + Timestamp time.Time `json:"timestamp"` + ID uint64 `json:"id"` + Hash string `json:"hash"` +} + +type TransactionParameter struct { + EntryPoint string `json:"entrypoint"` + Value []ParametersValue `json:"value"` +} + +type ParametersValue struct { + From string `json:"from_"` + Txs []TxsFormat `json:"txs"` +} + +type TxsFormat struct { + To string `json:"to_"` + Amount string `json:"amount"` + TokenID string `json:"token_id"` +} + +func (c *TZKT) request(req *http.Request, responseData interface{}) error { + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + // close the body only when we return an error + defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return ErrTooManyRequest + } + + errResp, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + return fmt.Errorf("tzkt api error: %s", errResp) + } + + err = json.NewDecoder(resp.Body).Decode(&responseData) + + return err +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..4eee81e --- /dev/null +++ b/client_test.go @@ -0,0 +1,197 @@ +package tzkt + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetContractToken(t *testing.T) { + tc := New("") + + token, err := tc.GetContractToken("KT1LjmAdYQCLBjwv4S2oFkEzyHVkomAf5MrW", "24216") + assert.NoError(t, err) + assert.Equal(t, token.Contract.Alias, "Versum Items") + + token2, err := tc.GetContractToken("KT1NVvPsNDChrLRH5K2cy6Sc9r1uuUwdiZQd", "5084") // token with string formats + assert.NoError(t, err) + assert.Len(t, token2.Metadata.Formats, 3) + + token3, err := tc.GetContractToken("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton", "777619") + assert.NoError(t, err) + assert.Len(t, token3.Metadata.Formats, 3) +} + +func TestRetrieveTokens(t *testing.T) { + tc := New("") + + ownedTokens, err := tc.RetrieveTokens("tz1RBi5DCVBYh1EGrcoJszkte1hDjrFfXm5C", time.Time{}, 0) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(ownedTokens), 1) + assert.GreaterOrEqual(t, ownedTokens[0].Balance, int64(1)) +} + +func TestGetTokenTransfers(t *testing.T) { + tc := New("") + + transfers, err := tc.GetTokenTransfers("KT1U6EHmNxJTkvaWJ4ThczG4FSDaHC21ssvi", "905625", 0) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(transfers), 1) + assert.Nil(t, transfers[0].From) + assert.Equal(t, transfers[0].TransactionID, uint64(251825029644288)) + assert.Nil(t, transfers[0].From) + assert.Equal(t, transfers[0].To.Address, "tz1QnNR17RHvXxDKHQEdRaAxrGL9hGysVcqT") +} + +func TestGetTransaction(t *testing.T) { + tc := New("") + + transaction, err := tc.GetTransaction(251825029644288) + assert.NoError(t, err) + assert.Equal(t, transaction.Hash, "ooJe9soP53x4dSBZR2mkEi1h3oQDCk5WZLaDBTVB3YzouC7dacQ") +} + +func TestGetTokenActivityTime(t *testing.T) { + tc := New("") + + activityTime, err := tc.GetTokenLastActivityTime("KT1U6EHmNxJTkvaWJ4ThczG4FSDaHC21ssvi", "905625") + assert.NoError(t, err) + + activityTestTime := time.Unix(1655686019, 0) + assert.GreaterOrEqual(t, activityTime.Sub(activityTestTime), time.Duration(0)) +} + +func TestGetTokenTransfersCount(t *testing.T) { + tc := New("") + + count, err := tc.GetTokenTransfersCount("KT1KEa8z6vWXDJrVqtMrAeDVzsvxat3kHaCE", "401199") + assert.NoError(t, err) + assert.GreaterOrEqual(t, count, 200) +} + +func TestGetTokenActivityTimeWithLimit200(t *testing.T) { + tc := New("") + + activityTime, err := tc.GetTokenLastActivityTime("KT1KEa8z6vWXDJrVqtMrAeDVzsvxat3kHaCE", "401199") + assert.NoError(t, err) + activityTestTime := time.Unix(1672001594, 0) + assert.GreaterOrEqual(t, activityTime.Sub(activityTestTime), time.Duration(0)) + + transfers, err := tc.GetTokenTransfers("KT1KEa8z6vWXDJrVqtMrAeDVzsvxat3kHaCE", "401199", 200) + assert.NoError(t, err) + assert.GreaterOrEqual(t, len(transfers), 200) +} + +func TestGetTokenActivityTimeNotExist(t *testing.T) { + tc := New("") + + activityTime, err := tc.GetTokenLastActivityTime("KT1U6EHmNxJTkvaWJ4ThczG4FSDaHC21ssvi", "0") + assert.Error(t, err, "no activities for this token") + assert.Equal(t, activityTime, time.Time{}) +} + +func TestGetTokenBalanceForOwner(t *testing.T) { + tc := New("") + + owner, lastTime, err := tc.GetTokenBalanceAndLastTimeForOwner("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton", "751194", "tz1bpvbjRGW1XHkALp4hFee6PKbnZCcoN9hE") + assert.NoError(t, err) + assert.Equal(t, owner, int64(1)) + assert.NotEqual(t, lastTime, time.Time{}) +} + +func TestGetArtworkMIMEType(t *testing.T) { + tc := New("") + + token, err := tc.GetContractToken("KT1XXcp2U2vAn4dENmKjJkyYb8svTEf2DxTY", "0") + assert.NoError(t, err) + assert.Len(t, token.Metadata.Formats, 3) + var mimeType string + for _, f := range token.Metadata.Formats { + if f.URI == token.Metadata.ArtifactURI { + mimeType = string(f.MIMEType) + break + } + } + + assert.Equal(t, mimeType, "image/jpeg") +} +func TestGetMIMETypeInArrayFormat(t *testing.T) { + tc := New("") + + token, err := tc.GetContractToken("KT1Q4SBM941oAeu69v8LsrfwSiEkhMWJiVrp", "105353509316641797498497312618436889009736347208140239997663486800489418099672") + assert.NoError(t, err) + assert.Len(t, token.Metadata.Formats, 3) + assert.Equal(t, "video/mp4", string(token.Metadata.Formats[0].MIMEType)) + assert.Equal(t, "image/jpeg", string(token.Metadata.Formats[1].MIMEType)) +} + +func TestHugeAmount(t *testing.T) { + tc := New("") + + accountTokenTime, err := time.Parse(time.RFC3339, "2022-10-01T09:00:00Z") + assert.NoError(t, err) + + _, err = tc.RetrieveTokens("tz1LiKcgzMA8E75vHtrr3wLk5Sx7r3GyMDNe", accountTokenTime, 0) + assert.NoError(t, err) + + token, err := tc.GetContractToken("KT1F8gkt9o4a2DKwHVsZv9akrF7ZbaYBHpMy", "0") + assert.NoError(t, err) + assert.Equal(t, int64(token.TotalSupply), int64(-1)) +} + +func TestGetTokenOwners(t *testing.T) { + tc := New("") + + var startTime time.Time + var querLimit = 50 + + owners, err := tc.GetTokenOwners("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton", "784317", querLimit, startTime) + assert.NoError(t, err) + assert.Len(t, owners, querLimit) + assert.Equal(t, owners[querLimit-1].Address, "tz1YuyeYd9VhZ5QWR1Q9X8ikiRcmMCvmKJWw") + assert.Equal(t, owners[querLimit-1].LastTime.Format(time.RFC3339), "2022-10-01T21:26:59Z") +} + +func TestGetTokenOwnersNow(t *testing.T) { + tc := New("") + + var querLimit = 50 + + owners, err := tc.GetTokenOwners("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton", "784317", querLimit, time.Now().Add(-time.Hour)) + assert.NoError(t, err) + assert.Len(t, owners, 0) +} + +func TestGetBigMapPointerForContractTokenMetadata(t *testing.T) { + tc := New("") + + p, err := tc.GetBigMapPointerForContractTokenMetadata("KT1U6EHmNxJTkvaWJ4ThczG4FSDaHC21ssvi") + assert.NoError(t, err) + assert.Equal(t, 149772, p) +} + +func TestGetBigMapValueByPointer(t *testing.T) { + tc := New("") + + p, err := tc.GetBigMapValueByPointer(149772, "589146") + assert.NoError(t, err) + assert.Equal(t, `{"token_id":"589146","token_info":{"":"697066733a2f2f516d64453569635a4450476b623457754d7036377a3647463678543833765344385264415954635478375a6a764b"}}`, string(p)) +} + +func TestGetTokenBalanceOfOwner(t *testing.T) { + tc := New("") + + value, err := tc.GetTokenBalanceOfOwner("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton", "818282", "tz1UaGzw3MRwn7G9WQ5rRDs8tMCPqNw2JyQE") + fmt.Printf("value: %+v\n", value) + assert.NoError(t, err) +} + +func TestGetBigMapsByContractAndPath(t *testing.T) { + tc := New("") + + ptr, err := tc.GetBigMapsByContractAndPath("KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton", "token_metadata") + assert.NoError(t, err) + assert.Equal(t, 514, ptr) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..100c99c --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/bitmark-inc/tzkt-go + +go 1.19 + +require github.com/stretchr/testify v1.8.2 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..29384c2 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/operation.go b/operation.go new file mode 100644 index 0000000..250b71b --- /dev/null +++ b/operation.go @@ -0,0 +1,59 @@ +package tzkt + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// GetTransactionByTx gets transaction details from a specific Tx +func (c *TZKT) GetTransactionByTx(hash string) ([]TransactionDetails, error) { + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: fmt.Sprintf("%s/%s", "/v1/operations/transactions", hash), + } + + var transactionDetails []TransactionDetails + + resp, err := c.client.Get(u.String()) + if err != nil { + return transactionDetails, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&transactionDetails); err != nil { + return transactionDetails, err + } + + return transactionDetails, nil +} + +func (c *TZKT) GetTransaction(id uint64) (TransactionDetails, error) { + v := url.Values{ + "id": []string{fmt.Sprintf("%d", id)}, + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/operations/transactions", + RawQuery: v.Encode(), + } + + var txs []TransactionDetails + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return TransactionDetails{}, err + } + if err := c.request(req, &txs); err != nil { + return TransactionDetails{}, err + } + + if len(txs) == 0 { + return TransactionDetails{}, fmt.Errorf("transaction not found") + } + return txs[0], nil +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..6c7244d --- /dev/null +++ b/token.go @@ -0,0 +1,273 @@ +package tzkt + +import ( + "fmt" + "net/http" + "net/url" + "time" +) + +// GetTokenBalanceOfOwner gets token balance of an owner +func (c *TZKT) GetTokenBalanceOfOwner(contract, tokenID, owner string) (int, error) { + v := url.Values{ + "account": []string{owner}, + "token.contract": []string{contract}, + "token.tokenId": []string{tokenID}, + "token.standard": []string{"fa2"}, + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/balances/count", + RawQuery: v.Encode(), + } + + var balance int + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return balance, err + } + if err := c.request(req, &balance); err != nil { + return balance, err + } + + return balance, nil +} + +// GetTokenOwners returns a list of TokenOwner for a specific token +func (c *TZKT) GetTokenOwners(contract, tokenID string, limit int, lastTime time.Time) ([]TokenOwner, error) { + v := url.Values{ + "token.contract": []string{contract}, + "token.tokenId": []string{tokenID}, + "balance.gt": []string{"0"}, + "token.standard": []string{"fa2"}, + "sort.asc": []string{"lastTime"}, + "limit": []string{fmt.Sprintf("%d", limit)}, + "select": []string{"account.address as address,balance,lastTime"}, + } + + rawQuery := v.Encode() + "&lastTime.ge=" + lastTime.UTC().Format(time.RFC3339) + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/balances", + RawQuery: rawQuery, + } + + var owners []TokenOwner + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + if err := c.request(req, &owners); err != nil { + return nil, err + } + + return owners, nil +} + +// GetTokenBalanceAndLastTimeForOwner returns balance and last activity time of an owner for a specific token +func (c *TZKT) GetTokenBalanceAndLastTimeForOwner(contract, tokenID, owner string) (int64, time.Time, error) { + v := url.Values{ + "token.contract": []string{contract}, + "token.tokenId": []string{tokenID}, + "balance.gt": []string{"0"}, + "account": []string{owner}, + "token.standard": []string{"fa2"}, + "select": []string{"lastTime,account.address as address,balance"}, + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/balances", + RawQuery: v.Encode(), + } + + var owners []TokenOwner + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return 0, time.Time{}, err + } + + if err := c.request(req, &owners); err != nil { + return 0, time.Time{}, err + } + + if len(owners) == 0 { + return 0, time.Time{}, fmt.Errorf("token not found") + } + + if len(owners) > 1 { + return 0, time.Time{}, fmt.Errorf("multiple token owners returned") + } + + return owners[0].Balance, owners[0].LastTime, nil +} + +// GetTokenLastActivityTime returns the timestamp of the last activity for a token +func (c *TZKT) GetTokenLastActivityTime(contract, tokenID string) (time.Time, error) { + v := url.Values{ + "token.contract": []string{contract}, + "token.tokenId": []string{tokenID}, + "token.standard": []string{"fa2"}, + "sort.desc": []string{"timestamp"}, + "limit": []string{"1"}, + "select": []string{"timestamp"}, + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/transfers", + RawQuery: v.Encode(), + } + + var activityTime []time.Time + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return time.Time{}, err + } + + if err := c.request(req, &activityTime); err != nil { + return time.Time{}, err + } + + if len(activityTime) == 0 { + return time.Time{}, fmt.Errorf("no activities for this token") + } + + return activityTime[0], nil +} + +func (c *TZKT) GetTokenTransfers(contract, tokenID string, limit int) ([]TokenTransfer, error) { + if limit == 0 { + limit = 100 + } + + v := url.Values{ + "token.contract": []string{contract}, + "token.tokenId": []string{tokenID}, + "token.standard": []string{"fa2"}, + "limit": []string{fmt.Sprint(limit)}, + "select": []string{"timestamp,from,to,transactionId,level"}, + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/transfers", + RawQuery: v.Encode(), + } + + var transfers []TokenTransfer + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + if err := c.request(req, &transfers); err != nil { + return nil, err + } + + return transfers, nil +} + +func (c *TZKT) GetTokenTransfersCount(contract, tokenID string) (int, error) { + v := url.Values{ + "token.contract": []string{contract}, + "token.tokenId": []string{tokenID}, + "token.standard": []string{"fa2"}, + } + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/transfers/count", + RawQuery: v.Encode(), + } + + var count int + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return 0, err + } + + if err := c.request(req, &count); err != nil { + return 0, err + } + + return count, nil +} + +// RetrieveTokens returns OwnedToken for a specific token. The OwnedToken object includes +// both balance and token information +func (c *TZKT) RetrieveTokens(owner string, lastTime time.Time, offset int) ([]OwnedToken, error) { + v := url.Values{ + "account": []string{owner}, + "limit": []string{"50"}, + "offset": []string{fmt.Sprintf("%d", offset)}, + "balance.ge": []string{"0"}, + "token.standard": []string{"fa2"}, + "sort": []string{"lastTime"}, + } + + // prevent QueryEscape for colons in time + rawQuery := v.Encode() + "&lastTime.gt=" + lastTime.UTC().Format(time.RFC3339) + + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens/balances", + RawQuery: rawQuery, + } + var ownedTokens []OwnedToken + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return ownedTokens, err + } + + if err := c.request(req, &ownedTokens); err != nil { + return ownedTokens, err + } + + return ownedTokens, nil +} + +func (c *TZKT) GetContractToken(contract, tokenID string) (Token, error) { + u := url.URL{ + Scheme: "https", + Host: c.endpoint, + Path: "/v1/tokens", + RawQuery: url.Values{ + "contract": []string{contract}, + "tokenId": []string{tokenID}, + }.Encode(), + } + + var tokenResponse []Token + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return Token{}, err + } + + if err := c.request(req, &tokenResponse); err != nil { + return Token{}, err + } + + if len(tokenResponse) == 0 { + return Token{}, fmt.Errorf("token not found") + } + + return tokenResponse[0], nil +}