diff --git a/api/config.go b/api/config.go index f3073a4a..3b0f456a 100644 --- a/api/config.go +++ b/api/config.go @@ -43,7 +43,7 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic device := service.NewDevice(deviceRepos, fingerprint) auth := service.NewAuth(repos, verification, device) - cost := service.NewCost(config.Redis) + cost := service.NewCost(config.Redis, repos) executor := service.NewExecutor() geofencing := service.NewGeofencing(config.Redis) diff --git a/pkg/internal/common/util.go b/pkg/internal/common/util.go index b96f4bd4..a5df5442 100644 --- a/pkg/internal/common/util.go +++ b/pkg/internal/common/util.go @@ -7,6 +7,7 @@ import ( "io" "math" "strconv" + "strings" libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/ethereum/go-ethereum/accounts" @@ -70,3 +71,14 @@ func SliceContains(elems []string, v string) bool { } return false } + +func StringContainsAny(target string, substrs []string) bool { + // convert target to lowercase + noSpaceLowerCase := strings.ReplaceAll(strings.ToLower(target), " ", "") + for _, substr := range substrs { + if strings.Contains(noSpaceLowerCase, strings.ReplaceAll(strings.ToLower(substr), " ", "")) { + return true + } + } + return false +} diff --git a/pkg/internal/unit21/transaction_test.go b/pkg/internal/unit21/transaction_test.go index 74e4b6ad..ebd28b52 100644 --- a/pkg/internal/unit21/transaction_test.go +++ b/pkg/internal/unit21/transaction_test.go @@ -168,12 +168,12 @@ func mockTransactionRows(mock sqlmock.Sqlmock, transaction model.Transaction, us AddRow(transaction.DestinationTxLegId, time.Now(), "1", transaction.TransactionAmount, assetId2, userId, instrumentId2) mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND deleted_at IS NULL").WithArgs(transaction.DestinationTxLegId).WillReturnRows(mockedTxLegRow2) - mockedAssetRow1 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "value_oracle"}). - AddRow(assetId1, "USD", "fiat USD", 6, false, "self") + mockedAssetRow1 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). + AddRow(assetId1, "USD", "fiat USD", 6, false, transaction.NetworkId, "self") mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND deleted_at IS NULL").WithArgs(assetId1).WillReturnRows(mockedAssetRow1) - mockedAssetRow2 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "value_oracle"}). - AddRow(assetId2, "Noose The Goose", "Noose the Goose NFT", 0, true, "joepegs.com") + mockedAssetRow2 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). + AddRow(assetId2, "Noose The Goose", "Noose the Goose NFT", 0, true, transaction.NetworkId, "joepegs.com") mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND deleted_at IS NULL").WithArgs(assetId2).WillReturnRows(mockedAssetRow2) mockedDeviceRow := sqlmock.NewRows([]string{"id", "type", "description", "fingerprint", "ip_addresses", "user_id"}). diff --git a/pkg/model/entity.go b/pkg/model/entity.go index 0ce203a5..41a9bb73 100644 --- a/pkg/model/entity.go +++ b/pkg/model/entity.go @@ -65,8 +65,10 @@ type Asset struct { Description string `json:"description" db:"description"` Decimals uint64 `json:"decimals" db:"decimals"` IsCrypto bool `json:"isCrypto" db:"is_crypto"` + NetworkId string `json:"networkId" db:"network_id"` ValueOracle sql.NullString `json:"valueOracle" db:"value_oracle"` ValueOracle2 sql.NullString `json:"valueOracle2" db:"value_oracle_2"` + Address sql.NullString `json:"address" db:"address"` } type UserToPlatform struct { diff --git a/pkg/repository/asset.go b/pkg/repository/asset.go index 29664c9d..d9561771 100644 --- a/pkg/repository/asset.go +++ b/pkg/repository/asset.go @@ -17,6 +17,7 @@ type Asset interface { Create(ctx context.Context, m model.Asset) (model.Asset, error) GetById(ctx context.Context, id string) (model.Asset, error) GetByName(ctx context.Context, name string) (model.Asset, error) + GetByKey(ctx context.Context, networkId string, address string) (model.Asset, error) Update(ctx context.Context, Id string, updates any) error } @@ -32,16 +33,8 @@ func (a asset[T]) Create(ctx context.Context, insert model.Asset) (model.Asset, m := model.Asset{} query, args, err := a.Named(` - WITH insert_asset AS ( - INSERT INTO asset (name, description, decimals, is_crypto, value_oracle, value_oracle_2) - VALUES(:name, :description, :decimals, :is_crypto, :value_oracle, :value_oracle_2) - ON CONFLICT (name) DO NOTHING - RETURNING * - ) - INSERT INTO asset_to_network (asset_id, network_id) - VALUES(insert_asset.id, :network_id) - ON CONFLICT (asset_id, network_id) DO NOTHING - `, insert) + INSERT INTO asset (name, description, decimals, is_crypto, network_id, value_oracle, value_oracle_2, address) + VALUES(:name, :description, :decimals, :is_crypto, :network_id, :value_oracle, :value_oracle_2, :address) RETURNING *`, insert) if err != nil { return m, libcommon.StringError(err) @@ -64,3 +57,12 @@ func (a asset[T]) GetByName(ctx context.Context, name string) (model.Asset, erro } return m, nil } + +func (a asset[T]) GetByKey(ctx context.Context, networkId string, address string) (model.Asset, error) { + m := model.Asset{} + err := a.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE network_id = $1 AND address = $2", a.Table), networkId, address) + if err != nil && err == sql.ErrNoRows { + return m, serror.NOT_FOUND + } + return m, nil +} diff --git a/pkg/service/cost.go b/pkg/service/cost.go index f6a10e69..9c3b8790 100644 --- a/pkg/service/cost.go +++ b/pkg/service/cost.go @@ -1,6 +1,8 @@ package service import ( + "context" + "database/sql" "fmt" "math" "math/big" @@ -13,6 +15,7 @@ import ( "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" "github.com/String-xyz/string-api/pkg/store" "github.com/pkg/errors" ) @@ -77,14 +80,16 @@ type CostCache struct { type Cost interface { EstimateTransaction(p EstimationParams, chain Chain) (estimate model.Estimate[float64], err error) LookupUSD(quantity float64, coins ...string) (float64, error) + AddCoinToAssetTable(id string, networkId string, address string) error } type cost struct { redis database.RedisStore // cached token and gas costs + repos repository.Repositories subnetTokenProxies map[CoinKey]CoinKey } -func NewCost(redis database.RedisStore) Cost { +func NewCost(redis database.RedisStore, repos repository.Repositories) Cost { // Temporarily hard-coding this to reduce future cost-of-change with database subnetTokenProxies := map[CoinKey]CoinKey{ // USDc DFK Subnet -> USDc Avalanche: @@ -94,6 +99,7 @@ func NewCost(redis database.RedisStore) Cost { } return &cost{ redis: redis, + repos: repos, subnetTokenProxies: subnetTokenProxies, } } @@ -118,6 +124,44 @@ func GetCoingeckoPlatformMapping() (map[uint64]string, map[string]uint64, error) return idToPlatform, platformToId, nil } +func GetCoingeckoCoinData(id string) (CoingeckoCoin, error) { + var coin CoingeckoCoin + err := common.GetJsonGeneric("https://api.coingecko.com/api/v3/coins/"+id+"?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false&sparkline=false", &coin) + if err != nil { + return coin, libcommon.StringError(err) + } + return coin, nil +} + +func (c cost) AddCoinToAssetTable(id string, networkId string, address string) error { + coinData, err := GetCoingeckoCoinData(id) + if err != nil { + return libcommon.StringError(err) + } + _, err = c.repos.Asset.GetByKey(context.Background(), networkId, address) + if err != nil && err == serror.NOT_FOUND { + // add it to the asset table + _, err := c.repos.Asset.Create(context.Background(), model.Asset{ + Name: coinData.Symbol, // Name in our database is the Symbol + Description: coinData.Name, // Description in our database is the Name, note: coingecko provides an actual description + Decimals: 18, // TODO: Get this from coinData - it's listed per network. Decimals only affects display. + IsCrypto: true, + ValueOracle: sql.NullString{String: id, Valid: true}, + NetworkId: networkId, + Address: sql.NullString{String: address, Valid: true}, + // TODO: Get second oracle data using data from first oracle + }) + if err != nil { + return libcommon.StringError(err) + } + } else if err != nil { + return libcommon.StringError(err) + } + // Check if we are on a new chain + + return nil +} + func GetCoingeckoCoinMapping() (map[string]string, error) { _, platformToId, err := GetCoingeckoPlatformMapping() if err != nil { @@ -243,6 +287,13 @@ func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (estimate mod if !ok { return estimate, errors.New("CoinGecko does not list token " + p.TokenAddrs[i]) } + + // Check if the token is in our database and add it if it's not in there + err = c.AddCoinToAssetTable(tokenName, chain.UUID, p.TokenAddrs[i]) + if err != nil { + return estimate, libcommon.StringError(err) + } + tokenCost, err := c.LookupUSD(costTokenEth, tokenName) if err != nil { return estimate, libcommon.StringError(err) diff --git a/pkg/service/cost_test.go b/pkg/service/cost_test.go index 9324b783..46887b57 100644 --- a/pkg/service/cost_test.go +++ b/pkg/service/cost_test.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -13,3 +14,9 @@ func TestGetTokenPrices(t *testing.T) { assert.True(t, ok) assert.Equal(t, "usd-coin", name) } + +func TestGetTokenData(t *testing.T) { + coin, err := GetCoingeckoCoinData("defi-kingdoms") + assert.NoError(t, err) + fmt.Printf("%+v", coin) +} diff --git a/pkg/service/transaction.go b/pkg/service/transaction.go index ca510703..12c3af08 100644 --- a/pkg/service/transaction.go +++ b/pkg/service/transaction.go @@ -638,7 +638,7 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio tokenAddresses := []string{} tokenAmounts := []big.Int{} for _, action := range request.Actions { - if strings.ToLower(strings.ReplaceAll(action.CxFunc, " ", "")) == "approve(address,uint256)" { + if common.StringContainsAny(action.CxFunc, []string{"approve", "transfer"}) { tokenAddresses = append(tokenAddresses, action.CxAddr) // It should be safe at this point to w3.I without panic tokenAmounts = append(tokenAmounts, *w3.I(action.CxParams[1])) @@ -655,7 +655,7 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio if err != nil { return res, eth, CallEstimate{}, libcommon.StringError(err) } - cost := NewCost(t.redis) + cost := NewCost(t.redis, t.repos) estimationParams := EstimationParams{ ChainId: chainId, CostETH: estimateEVM.Value, @@ -860,7 +860,7 @@ func (t transaction) tenderTransaction(ctx context.Context, p transactionProcess _, finish := Span(ctx, "service.transaction.tenderTransaction", SpanTag{"platformId": p.platformId}) defer finish() - cost := NewCost(t.redis) + cost := NewCost(t.redis, t.repos) trueWei := big.NewInt(0).Add(p.cumulativeValue, big.NewInt(int64(p.trueGas))) trueEth := common.WeiToEther(trueWei) trueUSD, err := cost.LookupUSD(trueEth, p.chain.CoingeckoName, p.chain.CoincapName)