feat: Waku v2 bridge

Issue #12610
This commit is contained in:
Michal Iskierko
2023-11-12 13:29:38 +01:00
parent 56e7bd01ca
commit 6d31343205
6716 changed files with 1982502 additions and 5891 deletions

View File

@@ -0,0 +1,459 @@
package alchemy
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/connection"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
const nftMetadataBatchLimit = 100
const contractMetadataBatchLimit = 100
func getBaseURL(chainID walletCommon.ChainID) (string, error) {
switch uint64(chainID) {
case walletCommon.EthereumMainnet:
return "https://eth-mainnet.g.alchemy.com", nil
case walletCommon.EthereumGoerli:
return "https://eth-goerli.g.alchemy.com", nil
case walletCommon.EthereumSepolia:
return "https://eth-sepolia.g.alchemy.com", nil
case walletCommon.OptimismMainnet:
return "https://opt-mainnet.g.alchemy.com", nil
case walletCommon.OptimismGoerli:
return "https://opt-goerli.g.alchemy.com", nil
case walletCommon.OptimismSepolia:
return "https://opt-sepolia.g.alchemy.com", nil
case walletCommon.ArbitrumMainnet:
return "https://arb-mainnet.g.alchemy.com", nil
case walletCommon.ArbitrumGoerli:
return "https://arb-goerli.g.alchemy.com", nil
case walletCommon.ArbitrumSepolia:
return "https://arb-sepolia.g.alchemy.com", nil
}
return "", thirdparty.ErrChainIDNotSupported
}
func (o *Client) ID() string {
return AlchemyID
}
func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
_, err := getBaseURL(chainID)
return err == nil
}
func (o *Client) IsConnected() bool {
return o.connectionStatus.IsConnected()
}
func getAPIKeySubpath(apiKey string) string {
if apiKey == "" {
return "demo"
}
return apiKey
}
func getNFTBaseURL(chainID walletCommon.ChainID, apiKey string) (string, error) {
baseURL, err := getBaseURL(chainID)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/nft/v3/%s", baseURL, getAPIKeySubpath(apiKey)), nil
}
type Client struct {
thirdparty.CollectibleContractOwnershipProvider
client *http.Client
apiKeys map[uint64]string
connectionStatus *connection.Status
}
func NewClient(apiKeys map[uint64]string) *Client {
for _, chainID := range walletCommon.AllChainIDs() {
if apiKeys[uint64(chainID)] == "" {
log.Warn("Alchemy API key not available for", "chainID", chainID)
}
}
return &Client{
client: &http.Client{Timeout: time.Minute},
apiKeys: apiKeys,
connectionStatus: connection.NewStatus(),
}
}
func (o *Client) doQuery(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return o.doWithRetries(req)
}
func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any) (*http.Response, error) {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return nil, err
}
payloadString := string(payloadJSON)
payloadReader := strings.NewReader(payloadString)
req, err := http.NewRequestWithContext(ctx, "POST", url, payloadReader)
if err != nil {
return nil, err
}
req.Header.Add("accept", "application/json")
req.Header.Add("content-type", "application/json")
return o.doWithRetries(req)
}
func (o *Client) doWithRetries(req *http.Request) (*http.Response, error) {
b := backoff.ExponentialBackOff{
InitialInterval: time.Millisecond * 1000,
RandomizationFactor: 0.1,
Multiplier: 1.5,
MaxInterval: time.Second * 32,
MaxElapsedTime: time.Second * 128,
Clock: backoff.SystemClock,
}
b.Reset()
op := func() (*http.Response, error) {
resp, err := o.client.Do(req)
if err != nil {
return nil, backoff.Permanent(err)
}
if resp.StatusCode == http.StatusOK {
return resp, nil
}
err = fmt.Errorf("unsuccessful request: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
if resp.StatusCode == http.StatusTooManyRequests {
return nil, err
}
return nil, backoff.Permanent(err)
}
return backoff.RetryWithData(op, &b)
}
func (o *Client) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: make([]thirdparty.CollectibleOwner, 0),
}
queryParams := url.Values{
"contractAddress": {contractAddress.String()},
"withTokenBalances": {"true"},
}
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
for {
url := fmt.Sprintf("%s/getOwnersForContract?%s", baseURL, queryParams.Encode())
resp, err := o.doQuery(ctx, url)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var alchemyOwnership CollectibleContractOwnership
err = json.Unmarshal(body, &alchemyOwnership)
if err != nil {
return nil, err
}
ownership.Owners = append(ownership.Owners, alchemyCollectibleOwnersToCommon(alchemyOwnership.Owners)...)
if alchemyOwnership.PageKey == "" {
break
}
queryParams["pageKey"] = []string{alchemyOwnership.PageKey}
}
return &ownership, nil
}
func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit)
}
func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
for _, contractAddress := range contractAddresses {
queryParams.Add("contractAddresses", contractAddress.String())
}
return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit)
}
func (o *Client) fetchOwnedAssets(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, queryParams url.Values, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
assets := new(thirdparty.FullCollectibleDataContainer)
queryParams["owner"] = []string{owner.String()}
queryParams["withMetadata"] = []string{"true"}
if len(cursor) > 0 {
queryParams["pageKey"] = []string{cursor}
assets.PreviousCursor = cursor
}
assets.Provider = o.ID()
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
for {
url := fmt.Sprintf("%s/getNFTsForOwner?%s", baseURL, queryParams.Encode())
resp, err := o.doQuery(ctx, url)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
container := OwnedNFTList{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
assets.Items = append(assets.Items, alchemyToCollectiblesData(chainID, container.OwnedNFTs)...)
assets.NextCursor = container.PageKey
if len(assets.NextCursor) == 0 {
break
}
queryParams["cursor"] = []string{assets.NextCursor}
if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
func getCollectibleUniqueIDBatches(ids []thirdparty.CollectibleUniqueID) []BatchTokenIDs {
batches := make([]BatchTokenIDs, 0)
for startIdx := 0; startIdx < len(ids); startIdx += nftMetadataBatchLimit {
endIdx := startIdx + nftMetadataBatchLimit
if endIdx > len(ids) {
endIdx = len(ids)
}
pageIDs := ids[startIdx:endIdx]
batchIDs := BatchTokenIDs{
IDs: make([]TokenID, 0, len(pageIDs)),
}
for _, id := range pageIDs {
batchID := TokenID{
ContractAddress: id.ContractID.Address,
TokenID: id.TokenID,
}
batchIDs.IDs = append(batchIDs.IDs, batchID)
}
batches = append(batches, batchIDs)
}
return batches
}
func (o *Client) fetchAssetsByBatchTokenIDs(ctx context.Context, chainID walletCommon.ChainID, batchIDs BatchTokenIDs) ([]thirdparty.FullCollectibleData, error) {
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/getNFTMetadataBatch", baseURL)
resp, err := o.doPostWithJSON(ctx, url, batchIDs)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
assets := NFTList{}
err = json.Unmarshal(body, &assets)
if err != nil {
return nil, err
}
ret := alchemyToCollectiblesData(chainID, assets.NFTs)
return ret, nil
}
func (o *Client) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
idsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(uniqueIDs)
for chainID, ids := range idsPerChainID {
batches := getCollectibleUniqueIDBatches(ids)
for _, batch := range batches {
assets, err := o.fetchAssetsByBatchTokenIDs(ctx, chainID, batch)
if err != nil {
return nil, err
}
ret = append(ret, assets...)
}
}
return ret, nil
}
func getContractAddressBatches(ids []thirdparty.ContractID) []BatchContractAddresses {
batches := make([]BatchContractAddresses, 0)
for startIdx := 0; startIdx < len(ids); startIdx += contractMetadataBatchLimit {
endIdx := startIdx + contractMetadataBatchLimit
if endIdx > len(ids) {
endIdx = len(ids)
}
pageIDs := ids[startIdx:endIdx]
batchIDs := BatchContractAddresses{
Addresses: make([]common.Address, 0, len(pageIDs)),
}
for _, id := range pageIDs {
batchIDs.Addresses = append(batchIDs.Addresses, id.Address)
}
batches = append(batches, batchIDs)
}
return batches
}
func (o *Client) fetchCollectionsDataByBatchContractAddresses(ctx context.Context, chainID walletCommon.ChainID, batchAddresses BatchContractAddresses) ([]thirdparty.CollectionData, error) {
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/getContractMetadataBatch", baseURL)
resp, err := o.doPostWithJSON(ctx, url, batchAddresses)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
collections := ContractList{}
err = json.Unmarshal(body, &collections)
if err != nil {
return nil, err
}
ret := alchemyToCollectionsData(chainID, collections.Contracts)
return ret, nil
}
func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
idsPerChainID := thirdparty.GroupContractIDsByChainID(contractIDs)
for chainID, ids := range idsPerChainID {
batches := getContractAddressBatches(ids)
for _, batch := range batches {
contractsData, err := o.fetchCollectionsDataByBatchContractAddresses(ctx, chainID, batch)
if err != nil {
return nil, err
}
ret = append(ret, contractsData...)
}
}
return ret, nil
}

View File

@@ -0,0 +1,197 @@
// nolint: misspell
package alchemy
const collectionJSON = `{
"address": "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d",
"name": "CryptoKitties",
"symbol": "CK",
"totalSupply": "2023564",
"tokenType": "ERC721",
"contractDeployer": "0xba52c75764d6F594735dc735Be7F1830CDf58dDf",
"deployedBlockNumber": 4605167,
"openSeaMetadata": {
"floorPrice": 0.003,
"collectionName": "CryptoKitties",
"collectionSlug": "cryptokitties",
"safelistRequestStatus": "verified",
"imageUrl": "https://i.seadn.io/gae/C272ZRW1RGGef9vKMePFSCeKc1Lw6U40wl9ofNVxzUxFdj84hH9xJRQNf-7wgs7W8qw8RWe-1ybKp-VKuU5D-tg?w=500&auto=format",
"description": "CryptoKitties is a game centered around breedable, collectible, and oh-so-adorable creatures we call CryptoKitties! Each cat is one-of-a-kind and 100% owned by you; it cannot be replicated, taken away, or destroyed.",
"externalUrl": null,
"twitterUsername": "CryptoKitties",
"discordUrl": "https://discord.gg/cryptokitties",
"bannerImageUrl": "https://i.seadn.io/gcs/static/banners/cryptokitties-banner2.png?w=500&auto=format",
"lastIngestedAt": "2024-02-05T23:13:45.000Z"
}
}`
const ownedCollectiblesJSON = `{
"ownedNfts": [
{
"contract": {
"address": "0x2b1870752208935fDA32AB6A016C01a27877CF12",
"name": null,
"symbol": null,
"totalSupply": null,
"tokenType": "ERC1155",
"contractDeployer": "0x5Df2E003CEcb0ebf69cBd8F7Fbb6F44F690331F2",
"deployedBlockNumber": 8088320,
"openSeaMetadata": {
"floorPrice": 0,
"collectionName": "Unidentified contract - SkdH8ZQtyB",
"collectionSlug": "unidentified-contract-skdh8zqtyb",
"safelistRequestStatus": "not_requested",
"imageUrl": null,
"description": null,
"externalUrl": null,
"twitterUsername": null,
"discordUrl": null,
"bannerImageUrl": null,
"lastIngestedAt": "2024-01-28T06:17:57.000Z"
},
"isSpam": null,
"spamClassifications": []
},
"tokenId": "50659039041325838222074459099120411190538227963344971355684955900852972814336",
"tokenType": "ERC1155",
"name": "HODL",
"description": "The enemy king sent a single message, written on a parchment stained by blood.\n“You are advised to submit without further delay, for if I bring my army into your land, I will destroy your hodlings, slay your people, and burn your city to ashes.”\nHodlers of ENJ sent a single word as reply:\n“If.”\nThe battle that followed does not come around too often, a battle that began every legend told about the warriors that gained eternal glory. \nThe battle that followed seemed like a lost one from the very beginning. \nThe enemy army was revealed at dawn, illuminated by the rising Sun.The ground shook as countless hordes marched towards a small band of men armed with shields, spears and swords.\nThe hodlers were outnumbered, one thousand to one. \nFear, doubt and uncertainty did not reach their hearts and minds - for they were born for this. \nEach hodler was bred for warfare, instructed in bloodshed, groomed to become a poet of death. \nA philosopher of war, blood and glory. \nEach man was forged into an invincible soldier that had a single driving force during each battle.\nStand your ground - at all costs. \nAs the swarm of enemies approached, the king yelled, asking his men: \n“Hodlers! What is your profession?”\n“HODL! HODL! HODL! HODL!!! HODL!!!!!” they replied, hitting spears against their shields. \nAn endless stream of arrows fell from the heavens only moments later, blocking out the Sun so they could fight in the shade. They emerged from the darkness without even a single scratch, protected by their legendary Enjin shields. \nWave after wave, their enemies rushed towards their doom, as they were met with cold tips of thrusting spears and sharp edges of crimson swords.\nAgainst all odds, the wall of men and steel held against the never-ending, shilling swarm. \nWhat was left of the enemy army retreated, fleeing in absolute panic and indisputable terror.\nBathed in blood, the ENJ hodlers were victorious.\nTheir story will be told for thousands of years, immortalized with divine blocks and chains.\n* * *\n“HODL” was minted in 2018 for our amazing community of epic Enjin HODLers. We are extremely grateful for the trust you've put in us and the products we're making - and the mission we're trying to accomplish, and hope youll love this token of our appreciation. ",
"tokenUri": "https://cdn.enjin.io/mint/meta/70000000000001b2.json",
"image": {
"cachedUrl": "https://nft-cdn.alchemy.com/eth-mainnet/c5c93ffa8146ade7d3694c0f28463f0c",
"thumbnailUrl": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/c5c93ffa8146ade7d3694c0f28463f0c",
"pngUrl": "https://res.cloudinary.com/alchemyapi/image/upload/convert-png/eth-mainnet/c5c93ffa8146ade7d3694c0f28463f0c",
"contentType": "image/jpeg",
"size": 256926,
"originalUrl": "https://cdn.enjin.io/mint/image/70000000000001b2.jpg"
},
"raw": {
"tokenUri": "https://cdn.enjin.io/mint/meta/70000000000001b2.json",
"metadata": {
"name": "HODL",
"description": "The enemy king sent a single message, written on a parchment stained by blood.\n“You are advised to submit without further delay, for if I bring my army into your land, I will destroy your hodlings, slay your people, and burn your city to ashes.”\nHodlers of ENJ sent a single word as reply:\n“If.”\nThe battle that followed does not come around too often, a battle that began every legend told about the warriors that gained eternal glory. \nThe battle that followed seemed like a lost one from the very beginning. \nThe enemy army was revealed at dawn, illuminated by the rising Sun.The ground shook as countless hordes marched towards a small band of men armed with shields, spears and swords.\nThe hodlers were outnumbered, one thousand to one. \nFear, doubt and uncertainty did not reach their hearts and minds - for they were born for this. \nEach hodler was bred for warfare, instructed in bloodshed, groomed to become a poet of death. \nA philosopher of war, blood and glory. \nEach man was forged into an invincible soldier that had a single driving force during each battle.\nStand your ground - at all costs. \nAs the swarm of enemies approached, the king yelled, asking his men: \n“Hodlers! What is your profession?”\n“HODL! HODL! HODL! HODL!!! HODL!!!!!” they replied, hitting spears against their shields. \nAn endless stream of arrows fell from the heavens only moments later, blocking out the Sun so they could fight in the shade. They emerged from the darkness without even a single scratch, protected by their legendary Enjin shields. \nWave after wave, their enemies rushed towards their doom, as they were met with cold tips of thrusting spears and sharp edges of crimson swords.\nAgainst all odds, the wall of men and steel held against the never-ending, shilling swarm. \nWhat was left of the enemy army retreated, fleeing in absolute panic and indisputable terror.\nBathed in blood, the ENJ hodlers were victorious.\nTheir story will be told for thousands of years, immortalized with divine blocks and chains.\n* * *\n“HODL” was minted in 2018 for our amazing community of epic Enjin HODLers. We are extremely grateful for the trust you've put in us and the products we're making - and the mission we're trying to accomplish, and hope youll love this token of our appreciation. ",
"image": "https://cdn.enjin.io/mint/image/70000000000001b2.jpg"
},
"error": null
},
"collection": {
"name": "Unidentified contract - SkdH8ZQtyB",
"slug": "unidentified-contract-skdh8zqtyb",
"externalUrl": null,
"bannerImageUrl": null
},
"mint": {
"mintAddress": null,
"blockNumber": null,
"timestamp": null,
"transactionHash": null
},
"owners": null,
"timeLastUpdated": "2024-01-03T19:11:04.681Z",
"balance": "1",
"acquiredAt": {
"blockTimestamp": null,
"blockNumber": null
}
},
{
"contract": {
"address": "0x3f6B1585AfeFc56433C8d28AA89dbc77af59278f",
"name": "Simpson Punk",
"symbol": "SIMPUNK",
"totalSupply": "1789",
"tokenType": "ERC721",
"contractDeployer": "0xa74E02F671e00eeFbf4e13D9D89B397523653E67",
"deployedBlockNumber": 18607457,
"openSeaMetadata": {
"floorPrice": 0.00069,
"collectionName": "SimpsonPunks",
"collectionSlug": "simpsonpunkseth",
"safelistRequestStatus": "not_requested",
"imageUrl": "https://raw.seadn.io/files/e7765f13c4658f514d0efc008ae7f300.png",
"description": "1,789 SimpsonPunks entered the Blockchain????",
"externalUrl": null,
"twitterUsername": "SimpsonPunksETH",
"discordUrl": null,
"bannerImageUrl": null,
"lastIngestedAt": "2024-01-31T00:06:00.000Z"
},
"isSpam": null,
"spamClassifications": []
},
"tokenId": "900",
"tokenType": "ERC721",
"name": "#900",
"description": "5,555 SimpsonPunks entered the Ethereum Blockchain🍩",
"tokenUri": "https://alchemy.mypinata.cloud/ipfs/bafybeidqbmbglapk2bkffa4o2ws5jhxnhlbdeqh7k6tk62pukse3xhvv2e/900.json",
"image": {
"cachedUrl": "https://nft-cdn.alchemy.com/eth-mainnet/52accf48dc609088738b15808fe07e8c",
"thumbnailUrl": "https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/52accf48dc609088738b15808fe07e8c",
"pngUrl": "https://res.cloudinary.com/alchemyapi/image/upload/convert-png/eth-mainnet/52accf48dc609088738b15808fe07e8c",
"contentType": "image/png",
"size": 21206,
"originalUrl": "https://ipfs.io/ipfs/bafybeib2metombffkovfzbjhljpcmobb5wqazsbpenz2drysgm7und47ym/900.png"
},
"raw": {
"tokenUri": "ipfs://bafybeidqbmbglapk2bkffa4o2ws5jhxnhlbdeqh7k6tk62pukse3xhvv2e/900.json",
"metadata": {
"date": 1700514890266,
"image": "ipfs://bafybeib2metombffkovfzbjhljpcmobb5wqazsbpenz2drysgm7und47ym/900.png",
"name": "#900",
"description": "5,555 SimpsonPunks entered the Ethereum Blockchain🍩",
"edition": 900,
"attributes": [
{
"value": "Background",
"trait_type": "layers"
},
{
"value": "Monkey",
"trait_type": "Face"
},
{
"value": "Sweatband Blue",
"trait_type": "Head"
},
{
"value": "Thin Full",
"trait_type": "Facial Hair"
},
{
"value": "Burger",
"trait_type": "Mouth"
}
],
"imageHash": "c1055d7f183405a5ec934bf6d83ac653740795cbf40031e3b34e957a352b471c",
"compiler": "HashLips Art Engine - NFTChef fork"
},
"error": null
},
"collection": {
"name": "SimpsonPunks",
"slug": "simpsonpunkseth",
"externalUrl": null,
"bannerImageUrl": null
},
"mint": {
"mintAddress": null,
"blockNumber": null,
"timestamp": null,
"transactionHash": null
},
"owners": null,
"timeLastUpdated": "2024-01-03T21:02:33.333Z",
"balance": "1",
"acquiredAt": {
"blockTimestamp": null,
"blockNumber": null
}
}
],
"totalCount": 53,
"validAt": {
"blockNumber": 19169748,
"blockHash": "0x95badbc2af9d0fa4ecfb614e6f24910e027b3e0a5c6fa4c32521ff5bc7754693",
"blockTimestamp": "2024-02-06T14:20:11Z"
},
"pageKey": "MHgyYjE4NzA3NTIyMDg5MzVmZGEzMmFiNmEwMTZjMDFhMjc4NzdjZjEyOjB4NzgwMDAwMDAwMDAwMDIyZTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDpmYWxzZQ=="
}`

View File

@@ -0,0 +1,249 @@
package alchemy
import (
"encoding/json"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const AlchemyID = "alchemy"
type TokenBalance struct {
TokenID *bigint.BigInt `json:"tokenId"`
Balance *bigint.BigInt `json:"balance"`
}
type CollectibleOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type CollectibleContractOwnership struct {
Owners []CollectibleOwner `json:"owners"`
PageKey string `json:"pageKey"`
}
func alchemyCollectibleOwnersToCommon(alchemyOwners []CollectibleOwner) []thirdparty.CollectibleOwner {
owners := make([]thirdparty.CollectibleOwner, 0, len(alchemyOwners))
for _, alchemyOwner := range alchemyOwners {
balances := make([]thirdparty.TokenBalance, 0, len(alchemyOwner.TokenBalances))
for _, alchemyBalance := range alchemyOwner.TokenBalances {
balances = append(balances, thirdparty.TokenBalance{
TokenID: &bigint.BigInt{Int: alchemyBalance.TokenID.Int},
Balance: alchemyBalance.Balance,
})
}
owner := thirdparty.CollectibleOwner{
OwnerAddress: alchemyOwner.OwnerAddress,
TokenBalances: balances,
}
owners = append(owners, owner)
}
return owners
}
type AttributeValue string
func (st *AttributeValue) UnmarshalJSON(b []byte) error {
var item interface{}
if err := json.Unmarshal(b, &item); err != nil {
return err
}
switch v := item.(type) {
case float64:
*st = AttributeValue(strconv.FormatFloat(v, 'f', 2, 64))
case int:
*st = AttributeValue(strconv.Itoa(v))
case string:
*st = AttributeValue(v)
}
return nil
}
type Attribute struct {
TraitType string `json:"trait_type"`
Value AttributeValue `json:"value"`
}
type RawMetadata struct {
Attributes []Attribute `json:"attributes"`
}
type RawFull struct {
RawMetadata RawMetadata `json:"metadata"`
}
type Raw struct {
RawMetadata interface{} `json:"metadata"`
}
func (r *Raw) UnmarshalJSON(b []byte) error {
raw := RawFull{
RawMetadata{
Attributes: make([]Attribute, 0),
},
}
// Field structure is not known in advance
_ = json.Unmarshal(b, &raw)
r.RawMetadata = raw.RawMetadata
return nil
}
type OpenSeaMetadata struct {
ImageURL string `json:"imageUrl"`
}
type Contract struct {
Address common.Address `json:"address"`
Name string `json:"name"`
Symbol string `json:"symbol"`
TokenType string `json:"tokenType"`
OpenSeaMetadata OpenSeaMetadata `json:"openseaMetadata"`
}
type ContractList struct {
Contracts []Contract `json:"contracts"`
}
type Image struct {
ImageURL string `json:"pngUrl"`
CachedAnimationURL string `json:"cachedUrl"`
OriginalAnimationURL string `json:"originalUrl"`
}
type Asset struct {
Contract Contract `json:"contract"`
TokenID *bigint.BigInt `json:"tokenId"`
Name string `json:"name"`
Description string `json:"description"`
Image Image `json:"image"`
Raw Raw `json:"raw"`
TokenURI string `json:"tokenUri"`
}
type OwnedNFTList struct {
OwnedNFTs []Asset `json:"ownedNfts"`
TotalCount *bigint.BigInt `json:"totalCount"`
PageKey string `json:"pageKey"`
}
type NFTList struct {
NFTs []Asset `json:"nfts"`
}
type BatchContractAddresses struct {
Addresses []common.Address `json:"contractAddresses"`
}
type BatchTokenIDs struct {
IDs []TokenID `json:"tokens"`
}
type TokenID struct {
ContractAddress common.Address `json:"contractAddress"`
TokenID *bigint.BigInt `json:"tokenId"`
}
func alchemyToCollectibleTraits(attributes []Attribute) []thirdparty.CollectibleTrait {
ret := make([]thirdparty.CollectibleTrait, 0, len(attributes))
caser := cases.Title(language.Und, cases.NoLower)
for _, orig := range attributes {
dest := thirdparty.CollectibleTrait{
TraitType: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
}
ret = append(ret, dest)
}
return ret
}
func alchemyToContractType(tokenType string) walletCommon.ContractType {
switch tokenType {
case "ERC721":
return walletCommon.ContractTypeERC721
case "ERC1155":
return walletCommon.ContractTypeERC1155
default:
return walletCommon.ContractTypeUnknown
}
}
func (c *Contract) toCollectionData(id thirdparty.ContractID) thirdparty.CollectionData {
ret := thirdparty.CollectionData{
ID: id,
ContractType: alchemyToContractType(c.TokenType),
Provider: AlchemyID,
Name: c.Name,
ImageURL: c.OpenSeaMetadata.ImageURL,
Traits: make(map[string]thirdparty.CollectionTrait, 0),
}
return ret
}
func (c *Asset) toCollectiblesData(id thirdparty.CollectibleUniqueID) thirdparty.CollectibleData {
rawMetadata := c.Raw.RawMetadata.(RawMetadata)
return thirdparty.CollectibleData{
ID: id,
ContractType: alchemyToContractType(c.Contract.TokenType),
Provider: AlchemyID,
Name: c.Name,
Description: c.Description,
ImageURL: c.Image.ImageURL,
AnimationURL: c.Image.CachedAnimationURL,
Traits: alchemyToCollectibleTraits(rawMetadata.Attributes),
TokenURI: c.TokenURI,
}
}
func (c *Asset) toCommon(id thirdparty.CollectibleUniqueID) thirdparty.FullCollectibleData {
contractData := c.Contract.toCollectionData(id.ContractID)
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(id),
CollectionData: &contractData,
}
}
func alchemyToCollectiblesData(chainID walletCommon.ChainID, l []Asset) []thirdparty.FullCollectibleData {
ret := make([]thirdparty.FullCollectibleData, 0, len(l))
for _, asset := range l {
id := thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
Address: asset.Contract.Address,
},
TokenID: asset.TokenID,
}
item := asset.toCommon(id)
ret = append(ret, item)
}
return ret
}
func alchemyToCollectionsData(chainID walletCommon.ChainID, l []Contract) []thirdparty.CollectionData {
ret := make([]thirdparty.CollectionData, 0, len(l))
for _, contract := range l {
id := thirdparty.ContractID{
ChainID: chainID,
Address: contract.Address,
}
item := contract.toCollectionData(id)
ret = append(ret, item)
}
return ret
}