From a8c9ea82f94964ad5b9bb9c4b5e19a3f3c6480bf Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 19 Jan 2023 17:27:22 -0800 Subject: [PATCH 1/3] Swap! --- pkg/service/cost.go | 5 +- pkg/service/executor.go | 85 ++++++++++++++++ pkg/service/swap.go | 203 +++++++++++++++++++++++++++++++++++++++ pkg/service/swap_test.go | 63 ++++++++++++ 4 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 pkg/service/swap.go create mode 100644 pkg/service/swap_test.go diff --git a/pkg/service/cost.go b/pkg/service/cost.go index be967b6a..cd518ab9 100644 --- a/pkg/service/cost.go +++ b/pkg/service/cost.go @@ -43,6 +43,7 @@ type CostCache struct { type Cost interface { EstimateTransaction(p EstimationParams, chain Chain) (model.Quote, error) LookupUSD(coin string, quantity float64) (float64, error) + CoingeckoUSD(coin string, quantity float64) (float64, error) } type cost struct { @@ -135,7 +136,7 @@ func (c cost) LookupUSD(coin string, quantity float64) (float64, error) { } if cacheObject == (CostCache{}) || (err == nil && time.Now().Unix()-cacheObject.Timestamp > c.getExternalAPICallInterval(10, 6)) { cacheObject.Timestamp = time.Now().Unix() - cacheObject.Value, err = c.coingeckoUSD(coin, 1) + cacheObject.Value, err = c.CoingeckoUSD(coin, 1) if err != nil { return 0, common.StringError(err) } @@ -169,7 +170,7 @@ func (c cost) lookupGas(network string) (float64, error) { return cacheObject.Value, nil } -func (c cost) coingeckoUSD(coin string, quantity float64) (float64, error) { +func (c cost) CoingeckoUSD(coin string, quantity float64) (float64, error) { requestURL := os.Getenv("COINGECKO_API_URL") + "simple/price?ids=" + coin + "&vs_currencies=usd" var res map[string]interface{} err := common.GetJsonGeneric(requestURL, &res) diff --git a/pkg/service/executor.go b/pkg/service/executor.go index 51d9b0b5..dd743f2a 100644 --- a/pkg/service/executor.go +++ b/pkg/service/executor.go @@ -27,6 +27,15 @@ type ContractCall struct { TxGasLimit string // Gwei gas limit ie "210000 gwei" } +type EncodedContractCall struct { + Data string // Hex encoded function, parameters etc + From string // Address + Gas string + GasPrice big.Int + To string // Address + Value string +} + type CallEstimate struct { Value big.Int Gas uint64 @@ -36,6 +45,7 @@ type CallEstimate struct { type Executor interface { Initialize(RPC string) error Initiate(call ContractCall) (string, *big.Int, error) + InitiateEncoded(call EncodedContractCall) (string, *big.Int, error) Estimate(call ContractCall) (CallEstimate, error) TxWait(txID string) (uint64, error) Close() error @@ -145,6 +155,81 @@ func (e executor) Estimate(call ContractCall) (CallEstimate, error) { return CallEstimate{Value: *value, Gas: estimatedGas, Success: true}, nil } +func (e executor) InitiateEncoded(call EncodedContractCall) (string, *big.Int, error) { + // Get private key + skStr, err := stringCommon.DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) + if err != nil { + return "", nil, stringCommon.StringError(err) + } + sk, err := crypto.ToECDSA(common.FromHex(skStr)) + if err != nil { + return "", nil, stringCommon.StringError(err) + } + // TODO: avoid panicking so that we get an intelligible error message + to := w3.A(call.To) + value := w3.I(call.Value) + // Get public key + publicKeyECDSA, ok := sk.Public().(*ecdsa.PublicKey) + if !ok { + return "", nil, stringCommon.StringError(errors.New("Estimate: Error casting public key to ECDSA")) + } + sender := crypto.PubkeyToAddress(*publicKeyECDSA) + + // Use provided gas limit + gasLimit := w3.I(call.Gas) + + // Get chainID from state + var chainId64 uint64 + err = e.client.Call(eth.ChainID().Returns(&chainId64)) + if err != nil { + return "", nil, stringCommon.StringError(err) + } + + // Get sender nonce + var nonce uint64 + err = e.client.Call(eth.Nonce(sender, nil).Returns(&nonce)) + if err != nil { + return "", nil, stringCommon.StringError(err) + } + + // // Get dynamic fee tx gas params + tipCap, _ := e.geth.SuggestGasTipCap(context.Background()) + feeCap, _ := e.geth.SuggestGasPrice(context.Background()) + + // // Function is already encoded along with parameters + // data := []byte(call.Data) + data := w3.B(call.Data) + + // Type conversion for chainID + chainIdBig := new(big.Int).SetUint64(chainId64) + + // Get signer type, this is used to encode the tx + signer := types.LatestSignerForChainID(chainIdBig) + + // Generate blockchain tx + dynamicFeeTx := types.DynamicFeeTx{ + ChainID: chainIdBig, + Nonce: nonce, + GasTipCap: tipCap, + GasFeeCap: feeCap, + Gas: gasLimit.Uint64(), + To: &to, + Value: value, + Data: data, + } + // Sign it + tx := types.MustSignNewTx(sk, signer, &dynamicFeeTx) + + // Call tx and retrieve hash + var hash common.Hash + err = e.client.Call(eth.SendTx(tx).Returns(&hash)) + if err != nil { + // Execution failed! + return "", nil, stringCommon.StringError(err) + } + return hash.String(), value, nil +} + func (e executor) Initiate(call ContractCall) (string, *big.Int, error) { // Get private key skStr, err := stringCommon.DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) diff --git a/pkg/service/swap.go b/pkg/service/swap.go new file mode 100644 index 00000000..ad85e5db --- /dev/null +++ b/pkg/service/swap.go @@ -0,0 +1,203 @@ +package service + +import ( + "fmt" + "math/big" + + "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/lmittmann/w3" + "github.com/pkg/errors" +) + +type SwapQuoteData struct { + yield *big.Int + gas *big.Int + chainId int +} + +// Hackathon +func nativeTokenOracleName(chainId int) string { + switch chainId { + case 1: + return "ethereum" + case 43114: + return "avalanche-2" + } + return "unknown" +} + +func SwapQuote(from string, to string, chainId int, amount *big.Int) (SwapQuoteData, error) { + quoteData := SwapQuoteData{} + request := + "https://api.1inch.io/v5.0/" + + fmt.Sprint(chainId) + + "/quote?fromTokenAddress=" + + from + "&toTokenAddress=" + + to + "&amount=" + fmt.Sprint(amount.String()) + var res map[string]interface{} + + err := common.GetJsonGeneric(request, &res) + if err != nil { + return quoteData, common.StringError(err) + } + + toAmount, valid := res["toTokenAmount"] + if !valid { + return quoteData, common.StringError(err) + } + gasAmount, valid := res["estimatedGas"] + if !valid { + return quoteData, common.StringError(err) + } + quoteData.yield = w3.I(toAmount.(string)) + quoteData.gas = w3.I(fmt.Sprint(gasAmount.(float64))) + quoteData.chainId = chainId + return quoteData, nil +} + +func SwapQuoteToUSD(quoteData SwapQuoteData) (float64, error) { + // Hackathon + c := NewCost(nil) + + // TODO: Use Cost.EstimateTransaction + oracleName := nativeTokenOracleName(quoteData.chainId) + nativeValueUSD, err := c.CoingeckoUSD(oracleName, 1) + if err != nil { + return 0, common.StringError(err) + } + yieldMinusGas := quoteData.yield.Sub(quoteData.yield, quoteData.gas) // hackathon + ethValue := common.WeiToEther(yieldMinusGas) + return ethValue * nativeValueUSD, nil +} + +func SwapQuoteUSD(from string, to string, chainId int, amount *big.Int) (float64, error) { + quoteData, err := SwapQuote(from, to, chainId, amount) + if err != nil { + return 0, common.StringError(err) + } + usd, err := SwapQuoteToUSD(quoteData) + if err != nil { + return 0, common.StringError(err) + } + return usd, nil +} + +func SwapQuoteCrossChain(from string, fromChain int, to string, toChain int, fromAmount *big.Int) (float64, error) { + fromValueUSD, err := SwapQuoteUSD(from, "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", fromChain, fromAmount) + if err != nil { + return 0, common.StringError(err) + } + c := NewCost(nil) // Hackathon + destinationNativeTokenCostUSD, err := c.CoingeckoUSD(nativeTokenOracleName(toChain), 1) + if err != nil { + return 0, common.StringError(err) + } + destinationNativeTokenAmount := fromValueUSD / destinationNativeTokenCostUSD + destinationNativeTokenWei := w3.I(fmt.Sprintf("%f ether", destinationNativeTokenAmount)) // Hackathon + destinationQuoteData, err := SwapQuote("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", to, toChain, destinationNativeTokenWei) + if err != nil { + return 0, common.StringError(err) + } + yieldMinusGas := destinationQuoteData.yield.Sub(destinationQuoteData.yield, destinationQuoteData.gas) // hackathon + destinationTokenAmount := common.WeiToEther(yieldMinusGas) + return destinationTokenAmount, nil +} + +func OffRamp(from string, fromChain int, fromAmount *big.Int) { + // Receive end users tokens + + // Trade them for native token + + // Send USD to the end users CC +} + +func Swap(from string, to string, chainId int, userAddr string, amount *big.Int) error { + // type spender struct { + // address string + // } + // var addr spender + var spender map[string]interface{} + + getSpenderAddr := "https://api.1inch.io/v5.0/" + fmt.Sprint(chainId) + "/approve/spender" + err := common.GetJsonGeneric(getSpenderAddr, &spender) + if err != nil { + return common.StringError(err) + } + addr, ok := spender["address"].(string) + if !ok { + return common.StringError(errors.New("Failed to unmarshal 1inch spender addr")) + } + addr = common.SanitizeChecksum(addr) // lol + fmt.Printf("\n\nADDR=%+v", spender["address"]) + + e := NewExecutor() + e.Initialize("https://api.avax.network/ext/bc/C/rpc") // Hackathon + call := ContractCall{ + CxAddr: from, + CxFunc: "approve(address,uint256)", + CxReturn: "bool", + CxParams: []string{addr, amount.String()}, + TxValue: "0", + TxGasLimit: "8000000", + } + approveTx, approveGas, err := e.Initiate(call) + if err != nil { + return common.StringError(err) + } + _, err = e.TxWait(approveTx) + if err != nil { + return common.StringError(err) + } + fmt.Printf("\nUsed %+v gas to approve swap %+v", common.WeiToEther(approveGas), approveTx) + + request := "https://api.1inch.io/v5.0/" + + fmt.Sprint(chainId) + + "/swap?fromTokenAddress=" + from + + "&toTokenAddress=" + to + + "&amount=" + amount.String() + + "&fromAddress=" + userAddr + + "&slippage=1" + + var res map[string]interface{} + + err = common.GetJsonGeneric(request, &res) + if err != nil { + return common.StringError(err) + } + + tx, ok := res["tx"].(map[string]interface{}) + if !ok { + return common.StringError(err) + } + + // HACKATHON GO GO GO + data := tx["data"].(string) + fromm := tx["from"].(string) + gas := tx["gas"].(float64) + gasPrice := tx["gasPrice"].(string) + too := tx["to"].(string) + value := tx["value"].(string) + + fromm = common.SanitizeChecksum(fromm) + too = common.SanitizeChecksum(too) + swap := EncodedContractCall{ + Data: data, + From: fromm, + Gas: fmt.Sprint(gas), + GasPrice: *w3.I(gasPrice), + To: too, + Value: value, + } + txId, swapGas, err := e.InitiateEncoded(swap) + fmt.Printf("\n\nINITIATED SWAP %+v", txId) + if err != nil { + return common.StringError(err) + } + _, err = e.TxWait(txId) + if err != nil { + return common.StringError(err) + } + + fmt.Printf("\n\nSWAPPED TX = %+v using %+v gas", txId, swapGas.String()) + return nil +} diff --git a/pkg/service/swap_test.go b/pkg/service/swap_test.go new file mode 100644 index 00000000..db2fb39e --- /dev/null +++ b/pkg/service/swap_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/joho/godotenv" + "github.com/lmittmann/w3" + "github.com/stretchr/testify/assert" +) + +func TestGetSwapQuote(t *testing.T) { + quoteData, err := SwapQuote( + "0xC38f41A296A4493Ff429F1238e030924A1542e50", // The token we have (SNOB) + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", // The token we want to exchange it for (Native Token) + 43114, // The chain we are on + w3.I("1000 ether")) // The amount of the token we want to swap (1000 SNOB) + assert.NoError(t, err) + fmt.Printf("1000 SNOB yields us %+v AVAX minus %+v gas", quoteData.yield.String(), quoteData.gas.String()) +} + +func TestGetSwapQuoteUSD(t *testing.T) { + err := godotenv.Load("../../.env") + assert.NoError(t, err) + usd, err := SwapQuoteUSD( + "0xC38f41A296A4493Ff429F1238e030924A1542e50", // The token we have (SNOB) + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", // The token we want to exchange it for (Native Token) + 43114, // The chain we are on + w3.I("1000 ether")) // The amount of the token we want to swap (1000 SNOB) + assert.NoError(t, err) + fmt.Printf("1000 SNOB yields us = %+v worth of AVAX", common.FloatToUSDString(usd)) + assert.NotEqual(t, 0, usd) +} + +func TestGetCrossChainQuote(t *testing.T) { + err := godotenv.Load("../../.env") + assert.NoError(t, err) + + res, err := SwapQuoteCrossChain( + "0xC38f41A296A4493Ff429F1238e030924A1542e50", // Token we have (SNOB) + 43114, // Chain we are starting from (Avalanche) + "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", // Token we want (SHIB) + 1, // Chain we are swapping into + w3.I("1000 ether")) // Amount of starting token we have (1000 SNOB) + assert.NoError(t, err) + + fmt.Printf("1000 SNOB yields us = %.2f SHIBA INU", res) + assert.NotEqual(t, 0, res) +} + +func TestGetSwapPayload(t *testing.T) { + err := godotenv.Load("../../.env") + assert.NoError(t, err) + + err = Swap( + "0xC38f41A296A4493Ff429F1238e030924A1542e50", // The token we have (SNOB) + "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", // The token we want to exchange it for (Native Token) + 43114, // The chain we are on + "0x44A4b9E2A69d86BA382a511f845CbF2E31286770", // The wallet address which will initiate the swap + w3.I("10 ether")) // The amount of the token we want to swap (10 SNOB) + assert.NoError(t, err) +} From 020f93d0c9284eb5e5af9f49acc0c1d21c4d5e50 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 23 Jan 2023 15:28:23 -0700 Subject: [PATCH 2/3] Adding offramp quote endpoint --- pkg/service/swap.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/pkg/service/swap.go b/pkg/service/swap.go index ad85e5db..619ce779 100644 --- a/pkg/service/swap.go +++ b/pkg/service/swap.go @@ -112,11 +112,7 @@ func OffRamp(from string, fromChain int, fromAmount *big.Int) { } func Swap(from string, to string, chainId int, userAddr string, amount *big.Int) error { - // type spender struct { - // address string - // } - // var addr spender - var spender map[string]interface{} + var spender map[string]interface{} // Address of 1inch smart contract getSpenderAddr := "https://api.1inch.io/v5.0/" + fmt.Sprint(chainId) + "/approve/spender" err := common.GetJsonGeneric(getSpenderAddr, &spender) @@ -127,8 +123,7 @@ func Swap(from string, to string, chainId int, userAddr string, amount *big.Int) if !ok { return common.StringError(errors.New("Failed to unmarshal 1inch spender addr")) } - addr = common.SanitizeChecksum(addr) // lol - fmt.Printf("\n\nADDR=%+v", spender["address"]) + addr = common.SanitizeChecksum(addr) // 1inch provides non-checksummed address e := NewExecutor() e.Initialize("https://api.avax.network/ext/bc/C/rpc") // Hackathon From 8113adf36dd04a7f0154afbd88858be1bccfbde8 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 23 Jan 2023 15:28:32 -0700 Subject: [PATCH 3/3] adding offramp quote --- README.md | 4 ++++ api/handler/quotes.go | 18 ++++++++++++++++++ pkg/model/swap.go | 32 ++++++++++++++++++++++++++++++++ pkg/service/transaction.go | 27 +++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 pkg/model/swap.go diff --git a/README.md b/README.md index 3929e2ce..6b307cd8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ run `go install` to get dependencies installed ### For local testing: ### run `go test` +### To run an individual test in verbose mode ### +`go test -run [TestName] [TestDirectory] -v -count 1` +i.e. `go test -run TestGetSwapPayload ./pkg/service -v -count 1` + ### Unit21: ### This is a 3rd party service that offers the ability to evaluate risk at a transaction level and identify fraud. A client file exists to connect to their API. Documentation is here: https://docs.unit21.ai/reference/entities-api You can create a test API key on the Unit21 dashboard. You will need to be setup as an Admin. Here are the instructions: https://docs.unit21.ai/reference/generate-api-keys diff --git a/api/handler/quotes.go b/api/handler/quotes.go index e736a28b..e92ae8f9 100644 --- a/api/handler/quotes.go +++ b/api/handler/quotes.go @@ -11,6 +11,7 @@ import ( type Quotes interface { Quote(c echo.Context) error + OffRampQuote(c echo.Context) error RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) } @@ -47,6 +48,22 @@ func (q quote) Quote(c echo.Context) error { return c.JSON(http.StatusOK, res) } +func (q quote) OffRampQuote(c echo.Context) error { + var body model.OffRampRequest + err := c.Bind(&body) + if err != nil { + LogStringError(c, err, "quote: offrampquote bind") + return BadRequestError(c) + } + SanitizeChecksums(&body.FromToken, &body.UserAddress) + res, err := q.Service.OffRampQuote(body) + if err != nil { + LogStringError(c, err, "quote: offrampquote") + return c.JSON(http.StatusInternalServerError, JSONError{Message: "Quote Service Failed"}) + } + return c.JSON(http.StatusOK, res) +} + func (q quote) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { if g == nil { panic("No group attached to the Quote Handler") @@ -54,4 +71,5 @@ func (q quote) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { q.Group = g g.Use(ms...) g.POST("", q.Quote) + g.POST("/offramp", q.OffRampQuote) } diff --git a/pkg/model/swap.go b/pkg/model/swap.go new file mode 100644 index 00000000..76a68763 --- /dev/null +++ b/pkg/model/swap.go @@ -0,0 +1,32 @@ +package model + +type OffRampRequest struct { + UserAddress string `json:"userAddress"` + ChainID int `json:"chainID"` + FromToken string `json:"fromToken"` + Amount string `json:"amount"` +} + +type OffRampExecutionRequest struct { + OffRampRequest + Quote + Signature string `json:"signature"` + CardToken string `json:"cardToken"` +} + +type SwapRequest struct { + UserAddress string `json:"userAddress"` + ChainID int `json:"chainID"` + FromToken string `json:"fromToken"` + ToToken string `json:"toToken"` + Amount string `json:"amount"` +} + +type SwapCrossChainRequest struct { + UserAddress string `json:"userAddress"` + FromChain int `json:"fromChain"` + ToChain int `json:"toChain"` + FromToken string `json:"fromToken"` + ToToken string `json:"toToken"` + Amount string `json:"amount"` +} diff --git a/pkg/service/transaction.go b/pkg/service/transaction.go index 76ab6e7d..eb177436 100644 --- a/pkg/service/transaction.go +++ b/pkg/service/transaction.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/lmittmann/w3" "github.com/pkg/errors" "github.com/String-xyz/string-api/pkg/internal/common" @@ -23,6 +24,7 @@ import ( type Transaction interface { Quote(d model.TransactionRequest) (model.ExecutionRequest, error) Execute(e model.ExecutionRequest, userId string, deviceId string) (model.TransactionReceipt, error) + OffRampQuote(o model.OffRampRequest) (model.OffRampExecutionRequest, error) } type TransactionRepos struct { @@ -754,3 +756,28 @@ func (t transaction) unit21Evaluate(transactionId string) (evaluation bool, err return } + +func (t transaction) OffRampQuote(o model.OffRampRequest) (model.OffRampExecutionRequest, error) { + res := model.OffRampExecutionRequest{OffRampRequest: o} + fromAmount := w3.I(o.Amount) + tokenUSD, err := SwapQuoteUSD(o.FromToken, "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", o.ChainID, fromAmount) + if err != nil { + return res, common.StringError(err) + } + res.TokenUSD = tokenUSD + res.ServiceUSD = res.TokenUSD * 0.03 + res.TotalUSD = res.TokenUSD - res.ServiceUSD + res.Timestamp = time.Now().Unix() + + // Sign entire payload + bytes, err := json.Marshal(res) + if err != nil { + return res, common.StringError(err) + } + signature, err := common.EVMSign(bytes, true) + if err != nil { + return res, common.StringError(err) + } + res.Signature = signature + return res, nil +}