diff --git a/api/handler/quotes.go b/api/handler/quotes.go index 846731d5..9e4d48d3 100644 --- a/api/handler/quotes.go +++ b/api/handler/quotes.go @@ -55,10 +55,13 @@ func (q quote) Quote(c echo.Context) error { return httperror.InvalidPayload400(c, err) } - SanitizeChecksums(&body.CxAddr, &body.UserAddress) - // Sanitize Checksum for body.CxParams? It might look like this: - for i := range body.CxParams { - SanitizeChecksums(&body.CxParams[i]) + // TODO: See if there's a way to batch these into a single call of SanitizeChecksums + SanitizeChecksums(&body.UserAddress) + for i := range body.Actions { + SanitizeChecksums(&body.Actions[i].CxAddr, &body.UserAddress) + for j := range body.Actions[i].CxParams { + SanitizeChecksums(&body.Actions[i].CxParams[j]) + } } platformId, ok := c.Get("platformId").(string) diff --git a/api/handler/transact.go b/api/handler/transact.go index b70ce56e..ee1364c2 100644 --- a/api/handler/transact.go +++ b/api/handler/transact.go @@ -72,10 +72,13 @@ func (t transaction) Transact(c echo.Context) error { transactionRequest := body.Quote.TransactionRequest - SanitizeChecksums(&transactionRequest.CxAddr, &transactionRequest.UserAddress) - // Sanitize Checksum for body.CxParams? It might look like this: - for i := range transactionRequest.CxParams { - SanitizeChecksums(&transactionRequest.CxParams[i]) + // TODO: These should already be sanitized by the quote, double check when there's time + SanitizeChecksums(&transactionRequest.UserAddress) + for i := range transactionRequest.Actions { + SanitizeChecksums(&transactionRequest.Actions[i].CxAddr, &transactionRequest.UserAddress) + for j := range transactionRequest.Actions[i].CxParams { + SanitizeChecksums(&transactionRequest.Actions[i].CxParams[j]) + } } ip := c.RealIP() diff --git a/pkg/internal/common/json.go b/pkg/internal/common/json.go index fb3ba96c..813f87c5 100644 --- a/pkg/internal/common/json.go +++ b/pkg/internal/common/json.go @@ -53,6 +53,20 @@ func GetJsonGeneric(url string, target interface{}) error { return nil } +func GetJsonDebug(url string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Get(url) + if err != nil { + return "", libcommon.StringError(err) + } + defer response.Body.Close() + jsonData, err := io.ReadAll(response.Body) + if err != nil { + return "", libcommon.StringError(err) + } + return string(jsonData), nil +} + func parseJSON[T any](b []byte) (T, error) { var r T if err := json.Unmarshal(b, &r); err != nil { diff --git a/pkg/model/transaction.go b/pkg/model/transaction.go index 2259133a..17d34554 100644 --- a/pkg/model/transaction.go +++ b/pkg/model/transaction.go @@ -37,19 +37,23 @@ type PaymentInfo struct { // User will pass this in for a quote and receive Execution Parameters type TransactionRequest struct { - UserAddress string `json:"userAddress" validate:"required,eth_addr"` // Used to keep track of user ie "0x44A4b9E2A69d86BA382a511f845CbF2E31286770" - AssetName string `json:"assetName" validate:"required,min=3,max=30"` // Used for receipt - ChainId uint64 `json:"chainId" validate:"required,number"` // Chain ID to execute on e.g. 80000. - CxAddr string `json:"contractAddress" validate:"required,eth_addr"` // Address of contract ie "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - CxFunc string `json:"contractFunction" validate:"required"` // Function declaration ie "mintTo(address)" - CxReturn string `json:"contractReturn"` // Function return type ie "uint256" - CxParams []string `json:"contractParameters"` // Function parameters ie ["0x000000000000000000BEEF", "32"] - TxValue string `json:"txValue"` // Amount of native token to send ie "0.08 ether" - TxGasLimit string `json:"gasLimit" validate:"required,number"` // Gwei gas limit ie "210000 gwei" + UserAddress string `json:"userAddress" validate:"required,eth_addr"` // Used to keep track of user ie "0x44A4b9E2A69d86BA382a511f845CbF2E31286770" + AssetName string `json:"assetName" validate:"required,min=3,max=30"` // Used for receipt + ChainId uint64 `json:"chainId" validate:"required,number"` // Chain ID to execute on e.g. 80000. + Actions []TransactionAction `json:"actions" validate:"required"` // Actions to execute +} + +type TransactionAction struct { + CxAddr string `json:"contractAddress" validate:"required,eth_addr"` // Address of contract ie "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + CxFunc string `json:"contractFunction" validate:"required"` // Function declaration ie "mintTo(address)" + CxReturn string `json:"contractReturn"` // Function return type ie "uint256" + CxParams []string `json:"contractParameters"` // Function parameters ie ["0x000000000000000000BEEF", "32"] + TxValue string `json:"txValue"` // Amount of native token to send ie "0.08 ether" + TxGasLimit string `json:"gasLimit" validate:"required,number"` // Gwei gas limit ie "210000 gwei" } type TransactionReceipt struct { - TxId string `json:"txId"` - TxURL string `json:"txUrl"` - TxTimestamp string `json:"txTimestamp"` + TxIds []string `json:"txIds"` + TxURLs []string `json:"txUrls"` + TxTimestamp string `json:"txTimestamp"` } diff --git a/pkg/service/cost.go b/pkg/service/cost.go index 275fa19e..4dd16fbe 100644 --- a/pkg/service/cost.go +++ b/pkg/service/cost.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "math" "math/big" "strconv" @@ -17,12 +18,12 @@ import ( ) type EstimationParams struct { - ChainId uint64 `json:"chainId"` - CostETH big.Int `json:"costETH"` - UseBuffer bool `json:"useBuffer"` - GasUsedWei uint64 `json:"gasUsedWei"` - CostToken big.Int `json:"costToken"` - TokenName string `json:"tokenName"` + ChainId uint64 `json:"chainId"` + CostETH big.Int `json:"costETH"` + UseBuffer bool `json:"useBuffer"` + GasUsedWei uint64 `json:"gasUsedWei"` + CostTokens []big.Int `json:"costToken"` + TokenAddrs []string `json:"tokenName"` } type OwlracleJSON struct { @@ -40,6 +41,34 @@ type OwlracleJSON struct { } `json:"speeds"` } +type CoingeckoPlatform struct { + Id string `json:"id"` + ChainIdentifier uint64 `json:"chain_identifier"` + Name string `json:"name"` + ShortName string `json:"shortname"` +} + +type CoingeckoCoin struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Platforms map[string]string `json:"platforms"` +} + +type CoinKey struct { + ChainId uint64 `json:"chainId"` + Address string `json:"address"` +} + +func (c CoinKey) String() string { + return fmt.Sprintf("%d:%s", c.ChainId, c.Address) +} + +type CoingeckoMapCache struct { + Timestamp int64 `json:"timestamp"` + Value map[string]string `json:"value"` +} + type CostCache struct { Timestamp int64 `json:"timestamp"` Value float64 `json:"value"` @@ -51,13 +80,101 @@ type Cost interface { } type cost struct { - redis database.RedisStore // cached token and gas costs + redis database.RedisStore // cached token and gas costs + subnetTokenProxies map[CoinKey]CoinKey } func NewCost(redis database.RedisStore) Cost { + // Temporarily hard-coding this to reduce future cost-of-change with database + subnetTokenProxies := map[CoinKey]CoinKey{ + // USDc DFK Subnet -> USDc Avalanche: + {ChainId: 53935, Address: "0x3AD9DFE640E1A9Cc1D9B0948620820D975c3803a"}: {ChainId: 43114, Address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"}, + // String USDc Fuji Testnet -> USDc Avalanche + {ChainId: 43113, Address: "0x671E35F91Cc497385f9f7d0dFCB7192848b1015b"}: {ChainId: 43114, Address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"}, + } return &cost{ - redis: redis, + redis: redis, + subnetTokenProxies: subnetTokenProxies, + } +} + +// Get a huge list of data from coingecko to look up token names by address +func GetCoingeckoPlatformMapping() (map[uint64]string, map[string]uint64, error) { + // get list of platform names from coingecko to create mapping to chainid + var platforms []CoingeckoPlatform + err := common.GetJsonGeneric("https://api.coingecko.com/api/v3/asset_platforms", &platforms) + if err != nil { + return map[uint64]string{}, map[string]uint64{}, libcommon.StringError(err) + } + + id_to_platform := make(map[uint64]string) + platform_to_id := make(map[string]uint64) + for _, p := range platforms { + if p.ChainIdentifier != 0 { + id_to_platform[p.ChainIdentifier] = p.Id + platform_to_id[p.Id] = p.ChainIdentifier + } + } + return id_to_platform, platform_to_id, nil +} + +func GetCoingeckoCoinMapping() (map[string]string, error) { + _, platform_to_id, err := GetCoingeckoPlatformMapping() + if err != nil { + return map[string]string{}, libcommon.StringError(err) + } + + // get list of platform names from coingecko to create mapping to chainid + var coins []CoingeckoCoin + err = common.GetJsonGeneric("https://api.coingecko.com/api/v3/coins/list?include_platform=true", &coins) + if err != nil { + return map[string]string{}, libcommon.StringError(err) + } + + coin_key_to_id := make(map[string]string) + for _, coin := range coins { + for key, val := range coin.Platforms { + // There's some weird data floating around in here. Ignore it. + if len(val) != 42 || val[:2] != "0x" { + continue + } + newKey := CoinKey{ + ChainId: platform_to_id[key], + Address: common.SanitizeChecksum(val), + }.String() + coin_key_to_id[newKey] = coin.ID + } + } + + return coin_key_to_id, nil +} + +// TODO: This logic is being reused, abstract it by templating and refactor +// i.e. LookupCache(cacheName string, rateLimit float, updateMethod func() (T, error)) (T, error) +func (c cost) LookupCoingeckoMapping() (map[string]string, error) { + cacheName := "coingecko_mapping" + cacheObject, err := store.GetObjectFromCache[CoingeckoMapCache](c.redis, cacheName) + if err != nil { + return map[string]string{}, libcommon.StringError(err) + } + if len(cacheObject.Value) == 0 || time.Now().Unix()-cacheObject.Timestamp > c.getExternalAPICallInterval(0.004, 1) { + updatedObject := CoingeckoMapCache{} + updatedObject.Timestamp = time.Now().Unix() + updatedObject.Value, err = GetCoingeckoCoinMapping() + // If update fails, return the old object + if err != nil { + return cacheObject.Value, libcommon.StringError(err) + } + err = store.PutObjectInCache(c.redis, cacheName, updatedObject) + // If store fails, return the new object anyway + if err != nil { + return updatedObject.Value, libcommon.StringError(err) + } + cacheObject = updatedObject } + + // Return the old or new object + return cacheObject.Value, nil } func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (estimate model.Estimate[float64], err error) { @@ -90,22 +207,44 @@ func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (estimate mod gasInUSD *= 1.0 + common.GasBuffer(chain.ChainId) } - // Query cost of token in USD if used and apply buffer - costToken := common.WeiToEther(&p.CostToken) - // tokenCost in contract call ERC-20 token costs - // Also for buying tokens directly - tokenCost, err := c.LookupUSD(costToken, p.TokenName) - if err != nil { + // // Query cost of token in USD if used and apply buffer + totalTokenCost := 0.0 + coinMapping, err := c.LookupCoingeckoMapping() + // Coingecko is going down during testing. Comment this out if needed. + if err != nil && len(coinMapping) == 0 { return estimate, libcommon.StringError(err) + } else if err != nil { + // TODO: Log error and continue + fmt.Printf("LookupCoingeckoMapping failed: %s", err) } - if p.UseBuffer { - tokenCost *= 1.0 + common.TokenBuffer(p.TokenName) + for i, costToken := range p.CostTokens { + // TODO: Get subnetTokenProxies from the database + coinKey := CoinKey{chain.ChainId, p.TokenAddrs[i]} + proxy := c.subnetTokenProxies[CoinKey{chain.ChainId, p.TokenAddrs[i]}] + if proxy.Address != "" { + coinKey = proxy + } + + costTokenEth := common.WeiToEther(&costToken) + + tokenName, ok := coinMapping[coinKey.String()] + if !ok { + return estimate, errors.New("CoinGecko does not list token " + p.TokenAddrs[i]) + } + tokenCost, err := c.LookupUSD(costTokenEth, tokenName) + if err != nil { + return estimate, libcommon.StringError(err) + } + if p.UseBuffer { + tokenCost *= 1.0 + common.TokenBuffer(tokenName) + } + totalTokenCost += tokenCost } // Compute service fee upcharge := chain.StringFee baseCheckoutFee := 0.3 - serviceFee := (transactionCost+gasInUSD+tokenCost)*upcharge + baseCheckoutFee + serviceFee := (transactionCost+gasInUSD+totalTokenCost)*upcharge + baseCheckoutFee // floor if transactionCost < 0.01 { @@ -118,11 +257,11 @@ func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (estimate mod // Round up to nearest cent transactionCost = centCeiling(transactionCost) gasInUSD = centCeiling(gasInUSD) - tokenCost = centCeiling(tokenCost) + totalTokenCost = centCeiling(totalTokenCost) serviceFee = centCeiling(serviceFee) // sum total - totalUSD := transactionCost + gasInUSD + tokenCost + serviceFee + totalUSD := transactionCost + gasInUSD + totalTokenCost + serviceFee // Round that up as well to account for any floating imprecision totalUSD = centCeiling(totalUSD) @@ -132,7 +271,7 @@ func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (estimate mod Timestamp: timestamp, BaseUSD: transactionCost, GasUSD: gasInUSD, - TokenUSD: tokenCost, + TokenUSD: totalTokenCost, ServiceUSD: serviceFee, TotalUSD: totalUSD, }, nil @@ -158,16 +297,20 @@ func (c cost) LookupUSD(quantity float64, coins ...string) (float64, error) { return 0.0, libcommon.StringError(err) } if cacheObject == (CostCache{}) || (err == nil && time.Now().Unix()-cacheObject.Timestamp > c.getExternalAPICallInterval(10, 6)) { - cacheObject.Timestamp = time.Now().Unix() // If coingecko is down, use coincap to get the price var empty interface{} - err = common.GetJson(config.Var.COINGECKO_API_URL+"ping", &empty) + err = common.GetJsonGeneric(config.Var.COINGECKO_API_URL+"ping", &empty) if err == nil { + // Only update timestamp if we reacquire the value + cacheObject.Timestamp = time.Now().Unix() cacheObject.Value, err = c.coingeckoUSD(coins[0]) if err != nil { return 0, libcommon.StringError(err) } - } else if len(coins) > 1 { + + } else if len(coins) > 1 && coins[1] != "" { + // Only update the timestamp if we reacquire the value + cacheObject.Timestamp = time.Now().Unix() cacheObject.Value, err = c.coincapUSD(coins[1]) if err != nil { @@ -180,6 +323,7 @@ func (c cost) LookupUSD(quantity float64, coins ...string) (float64, error) { } } + // If both services are down, use the last value we had return cacheObject.Value * quantity, nil } diff --git a/pkg/service/cost_test.go b/pkg/service/cost_test.go new file mode 100644 index 00000000..143e26e8 --- /dev/null +++ b/pkg/service/cost_test.go @@ -0,0 +1,15 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetTokenPrices(t *testing.T) { + keyMap, err := GetCoingeckoCoinMapping() + assert.NoError(t, err) + name, ok := keyMap[CoinKey{ChainId: 43114, Address: "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e"}] + assert.True(t, ok) + assert.Equal(t, "usd-coin", name) +} diff --git a/pkg/service/executor.go b/pkg/service/executor.go index 6aae4bc2..6dd62a74 100644 --- a/pkg/service/executor.go +++ b/pkg/service/executor.go @@ -37,17 +37,17 @@ type CallEstimate struct { type Executor interface { Initialize(network Chain) error - Initiate(call ContractCall) (string, *big.Int, error) - Estimate(call ContractCall) (CallEstimate, error) - TxWait(txId string) (uint64, error) + Initiate(calls []ContractCall) ([]string, *big.Int, error) + Estimate(calls []ContractCall) (CallEstimate, error) + TxWait(txIds []string) (uint64, error) Close() error GetByChainId() (uint64, error) GetBalance() (float64, error) - GetTokenIds(txId string) ([]string, error) - GetTokenQuantities(txId string) ([]string, error) - GetEventData(txId string, eventSignature string) ([]types.Log, error) - ForwardNonFungibleTokens(txId string, recipient string) ([]string, []string, error) - ForwardTokens(txId string, recipient string) ([]string, []string, []string, error) + GetTokenIds(txIds []string) ([]string, error) + GetTokenQuantities(txIds []string) ([]string, error) + GetEventData(txIds []string, eventSignature string) ([]types.Log, error) + ForwardNonFungibleTokens(txIds []string, recipient string) ([]string, []string, error) + ForwardTokens(txIds []string, recipient string) ([]string, []string, []string, error) } type executor struct { @@ -83,54 +83,95 @@ func (e *executor) Close() error { return nil } -func (e executor) Estimate(call ContractCall) (CallEstimate, error) { - // Generate blockchain message - msg, err := e.generateTransactionMessage(call) - if err != nil { - return CallEstimate{}, libcommon.StringError(err) +func (e executor) Estimate(calls []ContractCall) (CallEstimate, error) { + // Generate blockchain messages + w3calls := []w3types.Caller{} + estimatedGasses := make([]uint64, len(calls)) + totalValue := big.NewInt(0) + includesApprove := false + for i, call := range calls { + // TODO: Further optimize this to take in the calls array + msg, err := e.generateTransactionMessage(call, uint64(i)) + if err != nil { + return CallEstimate{}, libcommon.StringError(err) + } + totalValue = totalValue.Add(totalValue, msg.Value) + + w3calls = append(w3calls, eth.EstimateGas(&msg, nil).Returns(&estimatedGasses[i])) + + // simulation will not track the state of the blockchain after approval + if strings.Contains(strings.ToLower(strings.ReplaceAll(call.CxFunc, " ", "")), "approve") { + includesApprove = true + } + } + // Estimate gas of messages + err := e.client.Call(w3calls...) + _, ok := err.(w3.CallErrors) + if !includesApprove && (err != nil && ok) { + return CallEstimate{Value: *big.NewInt(0), Gas: 0, Success: false}, libcommon.StringError(err) } - // Estimate gas of message - var estimatedGas uint64 - err = e.client.Call(eth.EstimateGas(&msg, nil).Returns(&estimatedGas)) - if err != nil { - // Execution Will Revert! - return CallEstimate{Value: *msg.Value, Gas: estimatedGas, Success: false}, libcommon.StringError(err) + // Call(w3calls) should fill out estimatedGasses array + totalGas := uint64(0) + for _, gas := range estimatedGasses { + totalGas += gas } - return CallEstimate{Value: *msg.Value, Gas: estimatedGas, Success: true}, nil + return CallEstimate{Value: *totalValue, Gas: totalGas, Success: true}, nil } -func (e executor) Initiate(call ContractCall) (string, *big.Int, error) { - tx, err := e.generateTransactionRequest(call) - if err != nil { - return "", nil, libcommon.StringError(err) +func (e executor) Initiate(calls []ContractCall) ([]string, *big.Int, error) { + w3calls := []w3types.Caller{} + hashes := make([]ethcommon.Hash, len(calls)) + totalValue := big.NewInt(0) + for i, call := range calls { + // TODO: Further optimize this to take in the calls array + tx, err := e.generateTransactionRequest(call, uint64(i)) + if err != nil { + return []string{}, nil, libcommon.StringError(err) + } + totalValue = totalValue.Add(totalValue, tx.Value()) + w3calls = append(w3calls, eth.SendTx(&tx).Returns(&hashes[i])) + } + + // Call txs and retrieve hashes + err := e.client.Call(w3calls...) + callErrs, ok := err.(w3.CallErrors) + if err != nil && ok { + catErrs := "" + for _, callErr := range callErrs { + if callErr != nil { + catErrs += callErr.Error() + " " + } + } + return []string{}, nil, libcommon.StringError(errors.New(catErrs)) } - // Call tx and retrieve hash - var hash ethcommon.Hash - err = e.client.Call(eth.SendTx(&tx).Returns(&hash)) - if err != nil { - // Execution failed! - return "", nil, libcommon.StringError(err) + hashStrings := make([]string, len(hashes)) + for i := range hashes { + hashStrings[i] = hashes[i].String() } - return hash.String(), tx.Value(), nil + return hashStrings, totalValue, nil } -func (e executor) TxWait(txId string) (uint64, error) { - txHash := ethcommon.HexToHash(txId) - receipt := types.Receipt{} - for receipt.Status == 0 { - pendingReceipt, err := e.geth.TransactionReceipt(context.Background(), txHash) - // TransactionReceipt returns error "not found" while tx is pending - if err != nil && err.Error() != "not found" { - return 0, libcommon.StringError(err) - } - if pendingReceipt != nil { - receipt = *pendingReceipt +func (e executor) TxWait(txIds []string) (uint64, error) { + totalGasUsed := uint64(0) + for _, txId := range txIds { + txHash := ethcommon.HexToHash(txId) + receipt := types.Receipt{} + for receipt.Status == 0 { + pendingReceipt, err := e.geth.TransactionReceipt(context.Background(), txHash) + // TransactionReceipt returns error "not found" while tx is pending + if err != nil && err.Error() != "not found" { + return 0, libcommon.StringError(err) + } + if pendingReceipt != nil { + receipt = *pendingReceipt + } + // TODO: Sleep for a few ms to keep the cpu cooler } - // TODO: Sleep for a few ms to keep the cpu cooler + totalGasUsed += receipt.GasUsed } - return receipt.GasUsed, nil + return totalGasUsed, nil } func (e executor) GetByChainId() (uint64, error) { @@ -188,7 +229,7 @@ func (e executor) GetBalance() (float64, error) { return fbalance, nil // We like thinking in floats } -func (e executor) generateTransactionMessage(call ContractCall) (w3types.Message, error) { +func (e executor) generateTransactionMessage(call ContractCall, incrementNonce uint64) (w3types.Message, error) { sender, err := e.getAccount() if err != nil { return w3types.Message{}, libcommon.StringError(err) @@ -235,14 +276,14 @@ func (e executor) generateTransactionMessage(call ContractCall) (w3types.Message GasTipCap: tipCap, Value: value, Input: data, - Nonce: nonce, + Nonce: nonce + incrementNonce, }, nil } -func (e executor) generateTransactionRequest(call ContractCall) (types.Transaction, error) { +func (e executor) generateTransactionRequest(call ContractCall, incrementNonce uint64) (types.Transaction, error) { tx := types.Transaction{} - msg, err := e.generateTransactionMessage(call) + msg, err := e.generateTransactionMessage(call, 0) if err != nil { return tx, libcommon.StringError(err) } @@ -267,7 +308,7 @@ func (e executor) generateTransactionRequest(call ContractCall) (types.Transacti // Generate blockchain tx dynamicFeeTx := types.DynamicFeeTx{ ChainID: chainIdBig, - Nonce: msg.Nonce, + Nonce: msg.Nonce + incrementNonce, GasTipCap: tipCap, GasFeeCap: feeCap, Gas: w3.I(call.TxGasLimit).Uint64(), @@ -286,19 +327,21 @@ func (e executor) generateTransactionRequest(call ContractCall) (types.Transacti return tx, nil } -func (e executor) GetEventData(txId string, eventSignature string) ([]types.Log, error) { +func (e executor) GetEventData(txIds []string, eventSignature string) ([]types.Log, error) { events := []types.Log{} - receipt, err := e.geth.TransactionReceipt(context.Background(), ethcommon.HexToHash(txId)) - if err != nil { - return []types.Log{}, libcommon.StringError(err) - } + for _, txId := range txIds { + receipt, err := e.geth.TransactionReceipt(context.Background(), ethcommon.HexToHash(txId)) + if err != nil { + return []types.Log{}, libcommon.StringError(err) + } - event := crypto.Keccak256Hash([]byte(eventSignature)) + event := crypto.Keccak256Hash([]byte(eventSignature)) - // Iterate through the logs to find the transfer event and extract the token ID. - for _, log := range receipt.Logs { - if log.Topics[0].Hex() == event.Hex() { - events = append(events, *log) + // Iterate through the logs to find the transfer event and extract the token ID. + for _, log := range receipt.Logs { + if log.Topics[0].Hex() == event.Hex() { + events = append(events, *log) + } } } return events, nil @@ -324,8 +367,8 @@ func FilterEventData(logs []types.Log, indexes []int, hexValues []string) []type return matches } -func (e executor) GetTokenIds(txId string) ([]string, error) { - logs, err := e.GetEventData(txId, "Transfer(address,address,uint256)") +func (e executor) GetTokenIds(txIds []string) ([]string, error) { + logs, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { return []string{}, libcommon.StringError(err) } @@ -343,8 +386,8 @@ func (e executor) GetTokenIds(txId string) ([]string, error) { return tokenIds, nil } -func (e executor) GetTokenQuantities(txId string) ([]string, error) { - logs, err := e.GetEventData(txId, "Transfer(address,address,uint256)") +func (e executor) GetTokenQuantities(txIds []string) ([]string, error) { + logs, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { return []string{}, libcommon.StringError(err) } @@ -356,8 +399,8 @@ func (e executor) GetTokenQuantities(txId string) ([]string, error) { return quantities, nil } -func (e executor) ForwardNonFungibleTokens(txId string, recipient string) ([]string, []string, error) { - eventData, err := e.GetEventData(txId, "Transfer(address,address,uint256)") +func (e executor) ForwardNonFungibleTokens(txIds []string, recipient string) ([]string, []string, error) { + eventData, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { return []string{}, []string{}, libcommon.StringError(err) } @@ -367,9 +410,10 @@ func (e executor) ForwardNonFungibleTokens(txId string, recipient string) ([]str } // Filter events where recipient is our hot wallet toForward := FilterEventData(eventData, []int{2}, []string{hotWallet.String()}) - txIds := []string{} + filteredTxIds := []string{} tokenIds := []string{} gasUsed := big.NewInt(0) + calls := []ContractCall{} for _, log := range toForward { tokenId := new(big.Int).SetBytes(log.Topics[3].Bytes()).String() call := ContractCall{ @@ -385,18 +429,21 @@ func (e executor) ForwardNonFungibleTokens(txId string, recipient string) ([]str TxGasLimit: "800000", } tokenIds = append(tokenIds, tokenId) - forwardTxId, gas, err := e.Initiate(call) - txIds = append(txIds, forwardTxId) - gasUsed = gasUsed.Add(gasUsed, gas) + calls = append(calls, call) if err != nil { return txIds, tokenIds, libcommon.StringError(err) } } + + forwardTxIds, gas, err := e.Initiate(calls) + filteredTxIds = append(filteredTxIds, forwardTxIds...) + gasUsed = gasUsed.Add(gasUsed, gas) + return txIds, tokenIds, nil } -func (e executor) ForwardTokens(txId string, recipient string) ([]string, []string, []string, error) { - eventData, err := e.GetEventData(txId, "Transfer(address,address,uint256)") +func (e executor) ForwardTokens(txIds []string, recipient string) ([]string, []string, []string, error) { + eventData, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { return []string{}, []string{}, []string{}, libcommon.StringError(err) } @@ -406,10 +453,11 @@ func (e executor) ForwardTokens(txId string, recipient string) ([]string, []stri } // Filter events where recipient is our hot wallet toForward := FilterEventData(eventData, []int{2}, []string{hotWallet.String()}) - txIds := []string{} + filteredTxIds := []string{} tokens := []string{} quantities := []string{} gasUsed := big.NewInt(0) + calls := []ContractCall{} for _, log := range toForward { quantity := new(big.Int).SetBytes(log.Data).String() token := log.Address.String() @@ -427,12 +475,21 @@ func (e executor) ForwardTokens(txId string, recipient string) ([]string, []stri } tokens = append(tokens, token) quantities = append(quantities, quantity) - forwardTxId, gas, err := e.Initiate(call) - txIds = append(txIds, forwardTxId) - gasUsed = gasUsed.Add(gasUsed, gas) + calls = append(calls, call) + if err != nil { + return txIds, tokens, quantities, libcommon.StringError(err) + } + } + + // TODO: use this + if len(calls) > 0 { + forwardTxIds, gas, err := e.Initiate(calls) if err != nil { return txIds, tokens, quantities, libcommon.StringError(err) } + filteredTxIds = append(filteredTxIds, forwardTxIds...) + gasUsed = gasUsed.Add(gasUsed, gas) } + return txIds, tokens, quantities, nil } diff --git a/pkg/service/executor_test.go b/pkg/service/executor_test.go index da5b2be6..610baac9 100644 --- a/pkg/service/executor_test.go +++ b/pkg/service/executor_test.go @@ -29,7 +29,7 @@ func TestGetEventData(t *testing.T) { e, err := setupTest() assert.NoError(t, err) - eventData, err := e.GetEventData("0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56", + eventData, err := e.GetEventData([]string{"0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56"}, "Transfer(address,address,uint256)") assert.NoError(t, err) assert.Equal(t, "0x00000000000000000000000044a4b9e2a69d86ba382a511f845cbf2e31286770", eventData[0].Topics[2].Hex()) @@ -39,7 +39,7 @@ func TestGetTokensTransferred(t *testing.T) { e, err := setupTest() assert.NoError(t, err) - tokens, err := e.GetTokenIds("0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56") + tokens, err := e.GetTokenIds([]string{"0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56"}) assert.NoError(t, err) assert.Equal(t, []string{"167"}, tokens) @@ -49,7 +49,7 @@ func TestFilterEventData(t *testing.T) { e, err := setupTest() assert.NoError(t, err) - eventData, err := e.GetEventData("0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56", + eventData, err := e.GetEventData([]string{"0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56"}, "Transfer(address,address,uint256)") assert.NoError(t, err) diff --git a/pkg/service/quote_cache.go b/pkg/service/quote_cache.go index 8b397edc..48e0a44f 100644 --- a/pkg/service/quote_cache.go +++ b/pkg/service/quote_cache.go @@ -65,24 +65,28 @@ func (q quoteCache) PutCachedTransactionRequest(request model.TransactionRequest } func sanitizeTransactionRequest(request model.TransactionRequest) model.TransactionRequest { - // Structs are pointers + actions := []model.TransactionAction{} + for i := range request.Actions { + actions = append(actions, model.TransactionAction{ + CxAddr: request.Actions[i].CxAddr, + CxFunc: request.Actions[i].CxFunc, + CxReturn: request.Actions[i].CxReturn, + CxParams: append([]string{}, request.Actions[i].CxParams...), + TxValue: request.Actions[i].TxValue, + TxGasLimit: request.Actions[i].TxGasLimit, + }) + for j, param := range actions[i].CxParams { + if param == request.UserAddress { + actions[i].CxParams[j] = "*" + } + } + } sanitized := model.TransactionRequest{ UserAddress: request.UserAddress, + AssetName: request.AssetName, ChainId: request.ChainId, - CxAddr: request.CxAddr, - CxFunc: request.CxFunc, - CxReturn: request.CxReturn, - CxParams: append([]string{}, request.CxParams...), // So are arrays - TxValue: request.TxValue, // Get this from model.TransactionRequest because it requires no estimation - TxGasLimit: request.TxGasLimit, - } - // Treat the users address as a wildcard - for i, param := range sanitized.CxParams { - if param == sanitized.UserAddress { - sanitized.CxParams[i] = "*" - } + Actions: actions, } - sanitized.UserAddress = "*" return sanitized } diff --git a/pkg/service/transaction.go b/pkg/service/transaction.go index a4db412c..830a4545 100644 --- a/pkg/service/transaction.go +++ b/pkg/service/transaction.go @@ -13,6 +13,7 @@ import ( libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/String-xyz/go-lib/v2/database" serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/lmittmann/w3" "github.com/String-xyz/string-api/pkg/internal/checkout" "github.com/String-xyz/string-api/pkg/internal/common" @@ -78,10 +79,11 @@ type transactionProcessingData struct { PaymentStatus checkout.PaymentStatus PaymentId string recipientWalletId *string - txId *string + txIds []string cumulativeValue *big.Int trueGas *uint64 tokenIds string + tokenQuantities string } func (t transaction) Quote(ctx context.Context, d model.TransactionRequest, platformId string) (res model.Quote, err error) { @@ -90,7 +92,6 @@ func (t transaction) Quote(ctx context.Context, d model.TransactionRequest, plat // TODO: use prefab service to parse d and fill out known params res.TransactionRequest = d - // chain, err := model.ChainInfo(uint64(d.ChainId)) chain, err := ChainInfo(ctx, uint64(d.ChainId), t.repos.Network, t.repos.Asset) if err != nil { return res, libcommon.StringError(err) @@ -163,7 +164,16 @@ func (t transaction) Execute(ctx context.Context, e model.ExecutionRequest, user ctx2 := context.Background() go t.postProcess(ctx2, p) - return model.TransactionReceipt{TxId: *p.txId, TxURL: p.chain.Explorer + "/tx/" + *p.txId, TxTimestamp: time.Now().Format(time.RFC1123)}, nil + ids := []string{} + urls := []string{} + for i, id := range p.txIds { + // check if lowercase version of p.executionRequest.Quote.TransactionRequest.Actions[i].CxFunc contains the word "approve" + if !strings.Contains(strings.ToLower(p.executionRequest.Quote.TransactionRequest.Actions[i].CxFunc), "approve") { + ids = append(ids, id) + urls = append(urls, p.chain.Explorer+"/tx/"+id) + } + } + return model.TransactionReceipt{TxIds: ids, TxURLs: urls, TxTimestamp: time.Now().Format(time.RFC1123)}, nil } func (t transaction) transactionSetup(ctx context.Context, p transactionProcessingData) (transactionProcessingData, error) { @@ -328,21 +338,26 @@ func (t transaction) initiateTransaction(ctx context.Context, p transactionProce defer finish() request := p.executionRequest.Quote.TransactionRequest - call := ContractCall{ - CxAddr: request.CxAddr, - CxFunc: request.CxFunc, - CxReturn: request.CxReturn, - CxParams: request.CxParams, - TxValue: request.TxValue, - TxGasLimit: request.TxGasLimit, + calls := []ContractCall{} + for _, action := range request.Actions { + + call := ContractCall{ + CxAddr: action.CxAddr, + CxFunc: action.CxFunc, + CxReturn: action.CxReturn, + CxParams: action.CxParams, + TxValue: action.TxValue, + TxGasLimit: action.TxGasLimit, + } + calls = append(calls, call) } - txId, value, err := (*p.executor).Initiate(call) + txIds, value, err := (*p.executor).Initiate(calls) p.cumulativeValue = value if err != nil { return p, libcommon.StringError(err) } - p.txId = &txId + p.txIds = append(p.txIds, txIds...) // Create Response Tx leg eth := common.WeiToEther(value) @@ -368,7 +383,16 @@ func (t transaction) initiateTransaction(ctx context.Context, p transactionProce status := "Transaction Initiated" txAmount := p.cumulativeValue.String() - updateDB := &model.TransactionUpdates{Status: &status, TransactionHash: p.txId, TransactionAmount: &txAmount} + + hashesOtherThanApprove := []string{} + for i, txId := range p.txIds { + if !strings.Contains(strings.ToLower(p.executionRequest.Quote.TransactionRequest.Actions[i].CxFunc), "approve") { + hashesOtherThanApprove = append(hashesOtherThanApprove, txId) + } + } + + hashes := strings.Join(hashesOtherThanApprove, ", ") + updateDB := &model.TransactionUpdates{Status: &status, TransactionHash: &hashes, TransactionAmount: &txAmount} err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) if err != nil { return p, libcommon.StringError(err) @@ -401,7 +425,7 @@ func (t transaction) postProcess(ctx context.Context, p transactionProcessingDat } // confirm the Tx on the EVM - trueGas, err := confirmTx(executor, *p.txId) + trueGas, err := confirmTx(executor, p.txIds) p.trueGas = &trueGas if err != nil { log.Err(err).Msg("Failed to confirm transaction") @@ -419,21 +443,43 @@ func (t transaction) postProcess(ctx context.Context, p transactionProcessingDat // TODO: Handle error instead of returning it } - // Get the Token IDs which were transferred - tokenIds, err := executor.GetTokenIds(*p.txId) + // Get the Token IDs which were transferred to the API + tokenIds, err := executor.GetTokenIds(p.txIds) if err != nil { log.Err(err).Msg("Failed to get token ids") // TODO: Handle error instead of returning it } p.tokenIds = strings.Join(tokenIds, ",") - // Forward any tokens received to the user + // Forward any non fungible tokens received to the user // TODO: Use the TX ID/s from this in the receipt // TODO: Find a way to charge for the gas used in this transaction if err == nil { // There will be an error if no ERC721 transfer events were detected - executor.ForwardTokens(*p.txId, p.executionRequest.Quote.TransactionRequest.UserAddress) + executor.ForwardNonFungibleTokens(p.txIds, p.executionRequest.Quote.TransactionRequest.UserAddress) + } + + // Get the Token quantities which were transferred to the API + tokenQuantities, err := executor.GetTokenQuantities(p.txIds) + if err != nil { + log.Err(err).Msg("Failed to get token quantities") + // TODO: Handle error instead of returning it + } + + p.tokenQuantities = strings.Join(tokenQuantities, ",") + + if err == nil { + executor.ForwardTokens(p.txIds, p.executionRequest.Quote.TransactionRequest.UserAddress) + } + + // Cull any TXIDs from Approve(), the user and Unit21 and the receipt don't need them + for i, action := range p.executionRequest.Quote.TransactionRequest.Actions { + if strings.Contains(strings.ToLower(action.CxFunc), "approve") { + p.txIds = append(p.txIds[:i], p.txIds[i+1:]...) + } } + // TODO: Get the final gas total here and cache it to the quote cache. And use it for subsequent quotes. + // We can close the executor because we aren't using it after this executor.Close() @@ -506,9 +552,19 @@ func (t transaction) populateInitialTxModelData(ctx context.Context, e model.Exe // TODO populate transactionModel.PlatformId with UUID of customer // bytes, err := json.Marshal() - contractParams := pq.StringArray(e.Quote.TransactionRequest.CxParams) + // For now just concat everything + concatParams := []string{} + concatFuncs := []string{} + for _, action := range e.Quote.TransactionRequest.Actions { + concatParams = append(concatParams, "[") + concatParams = append(concatParams, action.CxParams...) + concatParams = append(concatParams, "]") + concatFuncs = append(concatFuncs, action.CxFunc+action.CxReturn) + } + + contractParams := pq.StringArray(concatParams) m.ContractParams = &contractParams - contractFunc := e.Quote.TransactionRequest.CxFunc + e.Quote.TransactionRequest.CxReturn + contractFunc := strings.Join(concatFuncs, ",") m.ContractFunc = &contractFunc asset, err := t.repos.Asset.GetByName(ctx, "USD") @@ -534,16 +590,19 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio } if recalculate { - call := ContractCall{ - CxAddr: request.CxAddr, - CxFunc: request.CxFunc, - CxReturn: request.CxReturn, - CxParams: request.CxParams, - TxValue: request.TxValue, - TxGasLimit: request.TxGasLimit, + calls := []ContractCall{} + for _, action := range request.Actions { + calls = append(calls, ContractCall{ + CxAddr: action.CxAddr, + CxFunc: action.CxFunc, + CxReturn: action.CxReturn, + CxParams: action.CxParams, + TxValue: action.TxValue, + TxGasLimit: action.TxGasLimit, + }) } // Estimate value and gas of Tx request - estimateEVM, err = executor.Estimate(call) + estimateEVM, err = executor.Estimate(calls) if err != nil { return res, 0, CallEstimate{}, libcommon.StringError(err) } @@ -555,6 +614,17 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio } } + // Factor in approvals to Token Cost + tokenAddresses := []string{} + tokenAmounts := []big.Int{} + for _, action := range request.Actions { + if strings.ToLower(strings.ReplaceAll(action.CxFunc, " ", "")) == "approve(address,uint256)" { + 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])) + } + } + // Calculate total eth estimate as float64 gas := new(big.Int) gas.SetUint64(estimateEVM.Gas) @@ -571,8 +641,8 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio CostETH: estimateEVM.Value, UseBuffer: useBuffer, GasUsedWei: estimateEVM.Gas, - CostToken: *big.NewInt(0), - TokenName: "", + CostTokens: tokenAmounts, + TokenAddrs: tokenAddresses, } // Estimate Cost in USD to execute Tx request @@ -757,8 +827,8 @@ func (t transaction) authCard(ctx context.Context, p transactionProcessingData) return p, nil } -func confirmTx(executor Executor, txId string) (uint64, error) { - trueGas, err := executor.TxWait(txId) +func confirmTx(executor Executor, txIds []string) (uint64, error) { + trueGas, err := executor.TxWait(txIds) if err != nil { return 0, libcommon.StringError(err) } @@ -872,14 +942,19 @@ func (t transaction) sendEmailReceipt(ctx context.Context, p transactionProcessi transactionRequest := p.executionRequest.Quote.TransactionRequest estimate := p.floatEstimate + explorers := []string{} + for _, id := range p.txIds { + explorers = append(explorers, p.chain.Explorer+"/tx"+id) + } + receiptParams := emailer.ReceiptGenerationParams{ ReceiptType: "NFT Purchase", // TODO: retrieve dynamically CustomerName: name, StringPaymentId: p.transactionModel.Id, PaymentDescriptor: p.executionRequest.Quote.TransactionRequest.AssetName, TransactionDate: time.Now().Format(time.RFC1123), - TransactionId: *p.txId, - TransactionExplorer: p.chain.Explorer + "/tx/" + *p.txId, + TransactionId: p.txIds[0], // For now assume there were 2 and the approval one was removed + TransactionExplorer: explorers[0], // For now assume there were 2 and the approval one was removed DestinationAddress: transactionRequest.UserAddress, DestinationExplorer: p.chain.Explorer + "/address/" + transactionRequest.UserAddress, PaymentMethod: p.cardAuthorization.Issuer + " " + p.cardAuthorization.Last4, @@ -946,21 +1021,25 @@ func (t transaction) isContractAllowed(ctx context.Context, platformId string, n _, finish := Span(ctx, "service.transaction.isContractAllowed", SpanTag{"platformId": platformId}) defer finish() - contract, err := t.repos.Contract.GetForValidation(ctx, request.CxAddr, networkId, platformId) - if err != nil && err == serror.NOT_FOUND { - return false, libcommon.StringError(serror.CONTRACT_NOT_ALLOWED) - } else if err != nil { - return false, libcommon.StringError(err) - } + for _, action := range request.Actions { + cxAddr := action.CxAddr + contract, err := t.repos.Contract.GetForValidation(ctx, cxAddr, networkId, platformId) + if err != nil && err == serror.NOT_FOUND { + return false, libcommon.StringError(serror.CONTRACT_NOT_ALLOWED) + } else if err != nil { + return false, libcommon.StringError(err) + } - if len(contract.Functions) == 0 { - return true, nil - } + if len(contract.Functions) == 0 { + continue + } - for _, function := range contract.Functions { - if function == request.CxFunc { - return true, nil + for _, function := range contract.Functions { + if function == action.CxFunc { + continue + } } } - return false, libcommon.StringError(serror.FUNC_NOT_ALLOWED) + + return true, nil }