diff --git a/examples/booking/mintnbuy.go b/examples/booking/mintnbuy.go index 1c84a138..e4c3d999 100644 --- a/examples/booking/mintnbuy.go +++ b/examples/booking/mintnbuy.go @@ -10,6 +10,7 @@ import ( typesv2 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v2" "google.golang.org/protobuf/types/known/emptypb" + "github.com/chain4travel/camino-messenger-contracts/go/contracts/erc20" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -17,20 +18,10 @@ import ( "go.uber.org/zap" "github.com/chain4travel/camino-messenger-bot/pkg/booking" + "github.com/chain4travel/camino-messenger-bot/pkg/cache" "github.com/chain4travel/camino-messenger-contracts/go/contracts/bookingtoken" ) -var zeroAddress = common.HexToAddress("0x0000000000000000000000000000000000000000") - -// https://columbus.caminoscan.com/token/0x5b1c852dad36854B0dFFF61d2C13F108D8E01975 -// https://caminoscan.com/token/0x026816DF82F78882DaC9370a35c497C254Ebd88E -var eurshToken = common.HexToAddress("0x5b1c852dad36854B0dFFF61d2C13F108D8E01975") - -var polygonToken = common.HexToAddress("0x0000000000000000000000000000000000001010") - -// https://polygonscan.com/token/0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6 -var wBtcToken = common.HexToAddress("0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6") // not on Camino - // Simple usage example for the BookingService func main() { logger, err := zap.NewDevelopment() @@ -42,6 +33,11 @@ func main() { sugar.Info("Starting Mint & Buy Example...") + tokenCache, err := cache.NewTokenCache(20) + if err != nil { + sugar.Errorf("Failed to create token cache: %v", err) + } + cmAccountAddrString := flag.String("cmaccount", "", "CMAccount Address. Ex: 0x....") // Take private key from command line pkString := flag.String("pk", "", "Private Key without the 0x notation") @@ -72,7 +68,7 @@ func main() { } sugar.Info("Creating Booking Service...") - bs, err := booking.NewService(cmAccountAddr, pk, client, sugar) + bs, err := booking.NewService(&cmAccountAddr, pk, client, sugar) if err != nil { sugar.Fatalf("Failed to create Booking Service: %v", err) } @@ -88,29 +84,16 @@ func main() { // expiration timestamp expiration := big.NewInt(time.Now().Add(time.Hour).Unix()) + var zeroAddress = common.HexToAddress("0x0000000000000000000000000000000000000000") + // https://columbus.caminoscan.com/token/0x5b1c852dad36854B0dFFF61d2C13F108D8E01975 + // var eurshToken = common.HexToAddress("0x5b1c852dad36854B0dFFF61d2C13F108D8E01975") + var testToken = common.HexToAddress("0x53A0b6A344C8068B211d47f177F0245F5A99eb2d") + var paymentToken common.Address = zeroAddress - var bigIntPrice *big.Int + var priceBigInt *big.Int var price *typesv2.Price - // https://polygonscan.com/unitconverter - // ### Simple Price type message Price - // - // Value of the price, this should be an integer converted to string. - // - // This field is a string intentionally. Because the currency can be a crypto - // currency, we need a reliable way to represent big integers as most of the crypto - // currencies have 18 decimals precision. - // - // Definition of the price message: The combination of "value" and "decimals" fields - // express always the value of the currency, not of the fraction of the currency [ - // ETH not wei, CAM and not aCAM, BTC and not Satoshi, EUR not EUR-Cents ] Be aware - // that partners should not do rounding with crypto currencies. - // - // price - // Example implementations: off-chain payment of 100 € or 100 $: - // value=10000 - // decimals=2 - // iso_currency=EUR or USD + // Example prices for ISO Currency priceEUR := &typesv2.Price{ Value: "10000", Decimals: 2, @@ -121,57 +104,20 @@ func main() { }, } - // On-chain payment of 100.65 EURSH - // value=10065 - // decimals=2 - // contract_address=0x... - // this currency has 5 decimals on Columbus and conclusively to create the - // transaction value, 10065 must be divided by 10^2 = 100.65 EURSH and created in - // its smallest fraction by multiplying 100.65 EURSH * 10^5 => 10065000 (example - // conversion to bigint without losing accuracy: bigint(10065) * 10^(5-2)) - + // Example prices for Token Currency priceEURSH := &typesv2.Price{ Value: "10065", Decimals: 2, Currency: &typesv2.Currency{ Currency: &typesv2.Currency_TokenCurrency{ TokenCurrency: &typesv2.TokenCurrency{ - ContractAddress: eurshToken.Hex(), + ContractAddress: testToken.Hex(), }, }, }, } - // TODO: call decimals on eurshToken (should get 5) - - // On-chain payment of 0.0065 BTC - // value=65 - // decimals=4 - // contract_address=0x... Using - // - // the contract address, we get the decimals decimals and the currency name or - // abbreviation: 8 decimals & WBTC Because we see 4 decimals specified in the - // message we divide 65 by 10^4 == 0.0065 WBTC (for showing in the front-end UIs) - // - // This currency has 8 decimals on-chain and conclusively to use the value of - // 0.0065 for on-chain operations must be converted to big integer as bigint(65) * - // 10^(8-4) == 650000 - - priceBTC := &typesv2.Price{ - Value: "65", - Decimals: 4, - Currency: &typesv2.Currency{ - Currency: &typesv2.Currency_TokenCurrency{ - TokenCurrency: &typesv2.TokenCurrency{}, - }, - }, - } - // On-chain payment of 1 nCAM value=1 decimals=9 this currency has denominator 18 on - // - // Columbus and conclusively to mint the value of 1 nCam must be divided by 10^9 = - // 0.000000001 CAM and minted in its smallest fraction by multiplying 0.000000001 * - // 10^18 => 1000000000 aCAM - + // Example prices for Native Token priceCAM := &typesv2.Price{ Value: "1", Decimals: 9, @@ -182,36 +128,51 @@ func main() { }, } - sugar.Infof("%v %v %v %v", priceEUR, priceEURSH, priceBTC, priceCAM) + sugar.Infof("%v %v %v %v", priceEUR, priceEURSH, priceCAM) sugar.Infof("%v", price) - // bigIntPrice, _ = bs.ConvertPriceToBigInt(*priceEURSH, int32(5)) - // bigIntPrice, _ = bs.ConvertPriceToBigInt(*priceCAM, int32(18)) - paymentToken = zeroAddress - bigIntPrice = big.NewInt(0) + priceBigInt = big.NewInt(0) // price = priceEURSH - // price = priceBTC // case of unsupported token? - price = priceCAM + price = priceEURSH - switch price.Currency.Currency.(type) { + switch currency := price.Currency.Currency.(type) { case *typesv2.Currency_NativeToken: - bigIntPrice, err = bs.ConvertPriceToBigInt(price.Value, price.Decimals, int32(18)) // CAM uses 18 decimals + priceBigInt, err = bs.ConvertPriceToBigInt(price.Value, price.Decimals, int32(18)) // CAM uses 18 decimals if err != nil { sugar.Errorf("Failed to convert price to big.Int: %v", err) return } - sugar.Infof("Converted the price big.Int: %v", bigIntPrice) + sugar.Infof("Converted the price big.Int: %v", priceBigInt) paymentToken = zeroAddress case *typesv2.Currency_TokenCurrency: - // Add logic to handle TokenCurrency - // if contract address is zeroAddress, then it is native token - sugar.Infof("TokenCurrency not supported yet") - return + if !common.IsHexAddress(currency.TokenCurrency.ContractAddress) { + sugar.Errorf("invalid contract address: %v", currency.TokenCurrency.ContractAddress) + } + contractAddress := common.HexToAddress(currency.TokenCurrency.ContractAddress) + tokenDecimals, found := tokenCache.Get(contractAddress) + if !found { + // Fetch decimals from the ERC20 contract + token, err := erc20.NewErc20(contractAddress, client) + if err != nil { + sugar.Errorf("failed to instantiate ERC20 contract: %v", err) + } + decimals, err := token.Decimals(&bind.CallOpts{}) + if err != nil { + sugar.Errorf("failed to fetch token decimals: %w", err) + } + // Cache decimals + tokenCache.Add(contractAddress, int32(decimals)) + tokenDecimals = int32(decimals) + } + priceBigInt, err = bs.ConvertPriceToBigInt(price.Value, price.Decimals, tokenDecimals) + if err != nil { + sugar.Errorf("Failed to convert price to big.Int: %v", err) + } + paymentToken = contractAddress case *typesv2.Currency_IsoCurrency: - // Add logic to handle IsoCurrency - sugar.Infof("IsoCurrency not supported yet") - return + priceBigInt = big.NewInt(0) + paymentToken = zeroAddress } // Mint a new booking token @@ -228,7 +189,7 @@ func main() { cmAccountAddr, // reservedFor address tokenURI, expiration, - bigIntPrice, + priceBigInt, paymentToken, ) if err != nil { diff --git a/examples/rpc/partner-plugin/handlers/mint_v1.go b/examples/rpc/partner-plugin/handlers/mint_v1.go index 2ee6a9f9..18d6a559 100644 --- a/examples/rpc/partner-plugin/handlers/mint_v1.go +++ b/examples/rpc/partner-plugin/handlers/mint_v1.go @@ -19,6 +19,44 @@ var _ bookv1grpc.MintServiceServer = (*MintServiceV1Server)(nil) type MintServiceV1Server struct{} +type PaymentConfigMintV1 struct { + NativeToken *typesv1.Price + Token *typesv1.Price + Offchain *typesv1.Price +} + +var priceConfigMintV1 = PaymentConfigMintV1{ + NativeToken: &typesv1.Price{ + Value: "1", + Decimals: 9, + Currency: &typesv1.Currency{ + Currency: &typesv1.Currency_NativeToken{ + NativeToken: &emptypb.Empty{}, + }, + }, + }, + Offchain: &typesv1.Price{ + Value: "1", + Decimals: 9, + Currency: &typesv1.Currency{ + Currency: &typesv1.Currency_IsoCurrency{ + IsoCurrency: typesv1.IsoCurrency_ISO_CURRENCY_EUR, // EUR + }, + }, + }, + Token: &typesv1.Price{ + Value: "100", + Decimals: 2, + Currency: &typesv1.Currency{ + Currency: &typesv1.Currency_TokenCurrency{ + TokenCurrency: &typesv1.TokenCurrency{ + ContractAddress: "0x87a131801978d1ffBa53a6D4180cBef3F8C9e760", + }, + }, + }, + }, +} + func (*MintServiceV1Server) Mint(ctx context.Context, _ *bookv1.MintRequest) (*bookv1.MintResponse, error) { md := metadata.Metadata{} @@ -35,15 +73,7 @@ func (*MintServiceV1Server) Mint(ctx context.Context, _ *bookv1.MintRequest) (*b BuyableUntil: ×tamppb.Timestamp{ Seconds: time.Now().Add(5 * time.Minute).Unix(), }, - Price: &typesv1.Price{ - Value: "1", - Decimals: 9, - Currency: &typesv1.Currency{ - Currency: &typesv1.Currency_NativeToken{ - NativeToken: &emptypb.Empty{}, - }, - }, - }, + Price: priceConfigMintV1.Token, // change to Token or Offchain to test different scenarios } log.Printf("CMAccount %s received request from CMAccount %s", md.Recipient, md.Sender) diff --git a/examples/rpc/partner-plugin/handlers/mint_v2.go b/examples/rpc/partner-plugin/handlers/mint_v2.go index fb2e4ec0..23a99603 100644 --- a/examples/rpc/partner-plugin/handlers/mint_v2.go +++ b/examples/rpc/partner-plugin/handlers/mint_v2.go @@ -20,6 +20,44 @@ var _ bookv2grpc.MintServiceServer = (*MintServiceV2Server)(nil) type MintServiceV2Server struct{} +type PaymentConfigMintV2 struct { + NativeToken *typesv2.Price + Token *typesv2.Price + Offchain *typesv2.Price +} + +var priceConfigMintV2 = PaymentConfigMintV2{ + NativeToken: &typesv2.Price{ + Value: "1", + Decimals: 9, + Currency: &typesv2.Currency{ + Currency: &typesv2.Currency_NativeToken{ + NativeToken: &emptypb.Empty{}, + }, + }, + }, + Offchain: &typesv2.Price{ + Value: "1", + Decimals: 9, + Currency: &typesv2.Currency{ + Currency: &typesv2.Currency_IsoCurrency{ + IsoCurrency: typesv2.IsoCurrency_ISO_CURRENCY_EUR, // EUR + }, + }, + }, + Token: &typesv2.Price{ + Value: "100", + Decimals: 2, + Currency: &typesv2.Currency{ + Currency: &typesv2.Currency_TokenCurrency{ + TokenCurrency: &typesv2.TokenCurrency{ + ContractAddress: "0x87a131801978d1ffBa53a6D4180cBef3F8C9e760", + }, + }, + }, + }, +} + func (*MintServiceV2Server) Mint(ctx context.Context, _ *bookv2.MintRequest) (*bookv2.MintResponse, error) { md := metadata.Metadata{} @@ -31,36 +69,12 @@ func (*MintServiceV2Server) Mint(ctx context.Context, _ *bookv2.MintRequest) (*b log.Printf("Responding to request: %s (MintV2)", md.RequestID) - // On-chain payment of 1 nCAM value=1 decimals=9 this currency has denominator 18 on - // - // Columbus and conclusively to mint the value of 1 nCam must be divided by 10^9 = - // 0.000000001 CAM and minted in its smallest fraction by multiplying 0.000000001 * - // 10^18 => 1000000000 aCAM response := bookv2.MintResponse{ MintId: &typesv1.UUID{Value: md.RequestID}, BuyableUntil: ×tamppb.Timestamp{ Seconds: time.Now().Add(5 * time.Minute).Unix(), }, - // NATIVE TOKEN EXAMPLE: - Price: &typesv2.Price{ - Value: "12345", - Decimals: 9, - Currency: &typesv2.Currency{ - Currency: &typesv2.Currency_NativeToken{ - NativeToken: &emptypb.Empty{}, - }, - }, - }, - // ISO CURRENCY EXAMPLE: - // Price: &typesv2.Price{ - // Value: "10000", - // Decimals: 2, - // Currency: &typesv2.Currency{ - // Currency: &typesv2.Currency_IsoCurrency{ - // IsoCurrency: typesv2.IsoCurrency_ISO_CURRENCY_EUR, - // }, - // }, - // }, + Price: priceConfigMintV2.NativeToken, // change to Token or Offchain to test different scenarios BookingTokenId: uint64(123456), ValidationId: &typesv1.UUID{Value: "123456"}, BookingTokenUri: "https://example.com/booking-token", diff --git a/go.mod b/go.mod index 6d2b5221..728386ed 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.23.1 require ( buf.build/gen/go/chain4travel/camino-messenger-protocol/grpc/go v1.5.1-20240924170438-a97744087df6.1 buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go v1.34.2-20240924170438-a97744087df6.2 - github.com/chain4travel/camino-messenger-contracts/go/contracts v0.0.0-20240918114804-fbc75cbe60fc + github.com/chain4travel/camino-messenger-contracts/go/contracts v0.0.0-20241011074135-9f5c573d3e25 github.com/ethereum/go-ethereum v1.14.9 github.com/go-viper/mapstructure/v2 v2.2.1 github.com/golang-migrate/migrate/v4 v4.18.1 github.com/google/uuid v1.6.0 + github.com/hashicorp/golang-lru v1.0.2 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jmoiron/sqlx v1.4.0 github.com/jonboulle/clockwork v0.4.0 diff --git a/go.sum b/go.sum index f4e11c93..d74138fb 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chain4travel/camino-messenger-contracts/go/contracts v0.0.0-20240918114804-fbc75cbe60fc h1:G4sK9VsBD90gCRBuyhzltXa1Ib9zsJPfcNHmf1XTRh8= -github.com/chain4travel/camino-messenger-contracts/go/contracts v0.0.0-20240918114804-fbc75cbe60fc/go.mod h1:omB9wucNB40bnXBQY9YXkgCT4wtCG9/PagvYntX9bDM= +github.com/chain4travel/camino-messenger-contracts/go/contracts v0.0.0-20241011074135-9f5c573d3e25 h1:eWYYHBmdeqitPsX18W1yczfDFGaiY+6VRxaovf1PDtM= +github.com/chain4travel/camino-messenger-contracts/go/contracts v0.0.0-20241011074135-9f5c573d3e25/go.mod h1:omB9wucNB40bnXBQY9YXkgCT4wtCG9/PagvYntX9bDM= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -140,6 +140,8 @@ github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpx github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= diff --git a/internal/app/app.go b/internal/app/app.go index df0f7566..c91b39c5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,9 +3,9 @@ package app import ( "context" "fmt" - "time" "github.com/chain4travel/camino-messenger-bot/config" + "github.com/chain4travel/camino-messenger-bot/pkg/cache" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/jonboulle/clockwork" @@ -63,6 +63,13 @@ func NewApp(ctx context.Context, cfg *config.Config, logger *zap.SugaredLogger) return nil, err } + // erc20 token cache + tokenCache, err := cache.NewTokenCache(20) + if err != nil { + logger.Errorf("Failed to create token cache: %v", err) + return nil, err + } + // partner-plugin rpc client rpcClient, err := client.NewClient(cfg.PartnerPlugin, logger) if err != nil { @@ -90,6 +97,7 @@ func NewApp(ctx context.Context, cfg *config.Config, logger *zap.SugaredLogger) cfg.CMAccountAddress, cfg.BookingTokenAddress, serviceRegistry, + tokenCache, ) if err != nil { logger.Errorf("Failed to create response handler: %v", err) diff --git a/internal/messaging/mint_v1.go b/internal/messaging/mint_v1.go index 5e79f934..42d4a990 100644 --- a/internal/messaging/mint_v1.go +++ b/internal/messaging/mint_v1.go @@ -6,13 +6,18 @@ import ( "math/big" "time" + "github.com/chain4travel/camino-messenger-bot/pkg/cache" + "github.com/chain4travel/camino-messenger-contracts/go/contracts/erc20" + bookv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/book/v1" typesv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v1" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "google.golang.org/protobuf/reflect/protoreflect" ) -func (h *evmResponseHandler) handleMintResponseV1(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage) bool { +func (h *evmResponseHandler) handleMintResponseV1(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage, tokenCache *cache.TokenCache) bool { mintResp, ok := response.(*bookv1.MintResponse) if !ok { return false @@ -52,7 +57,7 @@ func (h *evmResponseHandler) handleMintResponseV1(ctx context.Context, response } mintResp.BuyableUntil = buyableUntil - price, paymentToken, err := h.getPriceAndTokenV1(ctx, mintResp.Price) + price, paymentToken, err := h.getPriceAndTokenV1(ctx, mintResp.Price, tokenCache) if err != nil { errMessage := fmt.Sprintf("error minting NFT: %v", err) h.logger.Errorf(errMessage) @@ -115,20 +120,41 @@ func (h *evmResponseHandler) handleMintRequestV1(ctx context.Context, response p return false } -func (h *evmResponseHandler) getPriceAndTokenV1(_ context.Context, price *typesv1.Price) (*big.Int, common.Address, error) { +func (h *evmResponseHandler) getPriceAndTokenV1(ctx context.Context, price *typesv1.Price, tokenCache *cache.TokenCache) (*big.Int, common.Address, error) { priceBigInt := big.NewInt(0) paymentToken := zeroAddress - switch price.Currency.Currency.(type) { + var err error + switch currency := price.Currency.Currency.(type) { case *typesv1.Currency_NativeToken: - var err error priceBigInt, err = h.bookingService.ConvertPriceToBigInt(price.Value, price.Decimals, int32(18)) // CAM uses 18 decimals if err != nil { return nil, zeroAddress, fmt.Errorf("error minting NFT: %w", err) } case *typesv1.Currency_TokenCurrency: - // Add logic to handle TokenCurrency - // if contract address is zeroAddress, then it is native token - return nil, zeroAddress, fmt.Errorf("TokenCurrency not supported yet") + if !common.IsHexAddress(currency.TokenCurrency.ContractAddress) { + return nil, zeroAddress, fmt.Errorf("invalid contract address: %s", currency.TokenCurrency.ContractAddress) + } + contractAddress := common.HexToAddress(currency.TokenCurrency.ContractAddress) + tokenDecimals, found := tokenCache.Get(contractAddress) + if !found { + // Fetch decimals from the ERC20 contract + token, err := erc20.NewErc20(contractAddress, h.ethClient) + if err != nil { + return nil, zeroAddress, fmt.Errorf("failed to instantiate ERC20 contract: %w", err) + } + decimals, err := token.Decimals(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, zeroAddress, fmt.Errorf("failed to fetch token decimals: %w", err) + } + // Cache decimals + tokenCache.Add(contractAddress, int32(decimals)) + tokenDecimals = int32(decimals) + } + priceBigInt, err = h.bookingService.ConvertPriceToBigInt(price.Value, price.Decimals, tokenDecimals) + if err != nil { + return nil, zeroAddress, err + } + paymentToken = contractAddress case *typesv1.Currency_IsoCurrency: // For IsoCurrency, keep price as 0 and paymentToken as zeroAddress } diff --git a/internal/messaging/mint_v2.go b/internal/messaging/mint_v2.go index fc85f02f..38a6bdb3 100644 --- a/internal/messaging/mint_v2.go +++ b/internal/messaging/mint_v2.go @@ -9,11 +9,16 @@ import ( bookv2 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/book/v2" typesv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v1" typesv2 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v2" + + "github.com/chain4travel/camino-messenger-bot/pkg/cache" + "github.com/chain4travel/camino-messenger-contracts/go/contracts/erc20" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "google.golang.org/protobuf/reflect/protoreflect" ) -func (h *evmResponseHandler) handleMintResponseV2(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage) bool { +func (h *evmResponseHandler) handleMintResponseV2(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage, tokenCache *cache.TokenCache) bool { mintResp, ok := response.(*bookv2.MintResponse) if !ok { return false @@ -57,9 +62,9 @@ func (h *evmResponseHandler) handleMintResponseV2(ctx context.Context, response } mintResp.BuyableUntil = buyableUntil - price, paymentToken, err := h.getPriceAndTokenV2(ctx, mintResp.Price) + price, paymentToken, err := h.getPriceAndTokenV2(ctx, mintResp.Price, tokenCache) if err != nil { - errMessage := fmt.Sprintf("error minting NFT: %v", err) + errMessage := fmt.Sprintf("error getting price and payment token: %v", err) h.logger.Errorf(errMessage) h.AddErrorToResponseHeader(response, errMessage) return true @@ -119,20 +124,41 @@ func (h *evmResponseHandler) handleMintRequestV2(ctx context.Context, response p return false } -func (h *evmResponseHandler) getPriceAndTokenV2(_ context.Context, price *typesv2.Price) (*big.Int, common.Address, error) { +func (h *evmResponseHandler) getPriceAndTokenV2(ctx context.Context, price *typesv2.Price, tokenCache *cache.TokenCache) (*big.Int, common.Address, error) { priceBigInt := big.NewInt(0) paymentToken := zeroAddress - switch price.Currency.Currency.(type) { + var err error + switch currency := price.Currency.Currency.(type) { case *typesv2.Currency_NativeToken: - var err error priceBigInt, err = h.bookingService.ConvertPriceToBigInt(price.Value, price.Decimals, int32(18)) // CAM uses 18 decimals if err != nil { return nil, zeroAddress, fmt.Errorf("error minting NFT: %w", err) } case *typesv2.Currency_TokenCurrency: - // Add logic to handle TokenCurrency - // if contract address is zeroAddress, then it is native token - return nil, zeroAddress, fmt.Errorf("TokenCurrency not supported yet") + if !common.IsHexAddress(currency.TokenCurrency.ContractAddress) { + return nil, zeroAddress, fmt.Errorf("invalid contract address: %s", currency.TokenCurrency.ContractAddress) + } + contractAddress := common.HexToAddress(currency.TokenCurrency.ContractAddress) + tokenDecimals, found := tokenCache.Get(contractAddress) + if !found { + // Fetch decimals from the ERC20 contract + token, err := erc20.NewErc20(contractAddress, h.ethClient) + if err != nil { + return nil, zeroAddress, fmt.Errorf("failed to instantiate ERC20 contract: %w", err) + } + decimals, err := token.Decimals(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, zeroAddress, fmt.Errorf("failed to fetch token decimals: %w", err) + } + // Cache decimals + tokenCache.Add(contractAddress, int32(decimals)) + tokenDecimals = int32(decimals) + } + priceBigInt, err = h.bookingService.ConvertPriceToBigInt(price.Value, price.Decimals, tokenDecimals) + if err != nil { + return nil, zeroAddress, err + } + paymentToken = contractAddress case *typesv2.Currency_IsoCurrency: // For IsoCurrency, keep price as 0 and paymentToken as zeroAddress } diff --git a/internal/messaging/response_handler.go b/internal/messaging/response_handler.go index d34650c1..1d30ab42 100644 --- a/internal/messaging/response_handler.go +++ b/internal/messaging/response_handler.go @@ -22,6 +22,7 @@ import ( "github.com/chain4travel/camino-messenger-bot/internal/messaging/types" "github.com/chain4travel/camino-messenger-bot/internal/rpc/generated" "github.com/chain4travel/camino-messenger-bot/pkg/booking" + "github.com/chain4travel/camino-messenger-bot/pkg/cache" "github.com/chain4travel/camino-messenger-bot/pkg/events" "github.com/chain4travel/camino-messenger-contracts/go/contracts/bookingtoken" @@ -55,8 +56,9 @@ func NewResponseHandler( cmAccountAddress common.Address, bookingTokenAddress common.Address, serviceRegistry ServiceRegistry, + tokenCache *cache.TokenCache, ) (ResponseHandler, error) { - bookingService, err := booking.NewService(cmAccountAddress, botKey, ethClient, logger) + bookingService, err := booking.NewService(&cmAccountAddress, botKey, ethClient, logger) if err != nil { log.Printf("%v", err) return nil, err @@ -76,6 +78,7 @@ func NewResponseHandler( bookingToken: *bookingToken, serviceRegistry: serviceRegistry, evmEventListener: events.NewEventListener(ethClient, logger), + tokenCache: tokenCache, }, nil } @@ -88,6 +91,7 @@ type evmResponseHandler struct { bookingToken bookingtoken.Bookingtoken serviceRegistry ServiceRegistry evmEventListener *events.EventListener + tokenCache *cache.TokenCache } func (h *evmResponseHandler) HandleResponse(ctx context.Context, msgType types.MessageType, request protoreflect.ProtoMessage, response protoreflect.ProtoMessage) { @@ -97,7 +101,7 @@ func (h *evmResponseHandler) HandleResponse(ctx context.Context, msgType types.M return // TODO @evlekht we don't need this if true/false then do nothing } case generated.MintServiceV1Response: // supplier will act upon receiving a mint response by minting an NFT - if h.handleMintResponseV1(ctx, response, request) { + if h.handleMintResponseV1(ctx, response, request, h.tokenCache) { return // TODO @evlekht we don't need this if true/false then do nothing } case generated.MintServiceV2Request: // distributor will post-process a mint request to buy the returned NFT @@ -105,7 +109,7 @@ func (h *evmResponseHandler) HandleResponse(ctx context.Context, msgType types.M return // TODO @evlekht we don't need this if true/false then do nothing } case generated.MintServiceV2Response: // supplier will act upon receiving a mint response by minting an NFT - if h.handleMintResponseV2(ctx, response, request) { + if h.handleMintResponseV2(ctx, response, request, h.tokenCache) { return // TODO @evlekht we don't need this if true/false then do nothing } } diff --git a/pkg/booking/booking.go b/pkg/booking/booking.go index aade8e1b..35485386 100644 --- a/pkg/booking/booking.go +++ b/pkg/booking/booking.go @@ -7,7 +7,9 @@ import ( "math/big" "strings" + "github.com/chain4travel/camino-messenger-bot/pkg/cache" "github.com/chain4travel/camino-messenger-contracts/go/contracts/cmaccount" + "github.com/chain4travel/camino-messenger-contracts/go/contracts/erc20" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -17,17 +19,21 @@ import ( // Service provides minting and buying methods to interact with the CM Account contract. type Service struct { - client *ethclient.Client - logger *zap.SugaredLogger - cmAccount *cmaccount.Cmaccount - transactOpts *bind.TransactOpts - chainID *big.Int + client *ethclient.Client + logger *zap.SugaredLogger + cmAccount *cmaccount.Cmaccount + transactOpts *bind.TransactOpts + chainID *big.Int + cmAccountAddress *common.Address + TokenCache *cache.TokenCache } +var zeroAddress = common.HexToAddress("0x0000000000000000000000000000000000000000") + // NewService initializes a new Service. It sets up the transactor with the provided // private key and creates the CMAccount contract. func NewService( - cmAccountAddr common.Address, + cmAccountAddr *common.Address, privateKey *ecdsa.PrivateKey, client *ethclient.Client, logger *zap.SugaredLogger, @@ -51,17 +57,18 @@ func NewService( // transactOpts.GasPrice = big.NewInt(20000000000) // example gas price // Initialize the CMAccount - cmAccount, err := cmaccount.NewCmaccount(cmAccountAddr, client) + cmAccount, err := cmaccount.NewCmaccount(*cmAccountAddr, client) if err != nil { return nil, fmt.Errorf("failed to create CMAccount: %w", err) } return &Service{ - client: client, - logger: logger, - cmAccount: cmAccount, - transactOpts: transactOpts, - chainID: chainID, + client: client, + logger: logger, + cmAccount: cmAccount, + transactOpts: transactOpts, + chainID: chainID, + cmAccountAddress: cmAccountAddr, }, nil } @@ -86,7 +93,16 @@ func (bs *Service) MintBookingToken( if strings.TrimSpace(uri) == "" { return nil, fmt.Errorf("uri cannot be empty") } - + if paymentToken != zeroAddress { + erc20Contract, err := erc20.NewErc20(paymentToken, bs.client) + if err != nil { + return nil, fmt.Errorf("failed to instantiate ERC20 contract: %w", err) + } + + if err := bs.checkAndApproveAllowance(context.Background(), erc20Contract, reservedFor, *bs.cmAccountAddress, price); err != nil { + return nil, fmt.Errorf("error during token approval process: %w", err) + } + } // Call the MintBookingToken function from the contract tx, err := bs.cmAccount.MintBookingToken( bs.transactOpts, @@ -144,3 +160,47 @@ func (bs *Service) ConvertPriceToBigInt(value string, decimals int32, totalDecim return result, nil } + +// checkAndApproveAllowance checks if the allowance is sufficient and approves tokens if necessary +func (bs *Service) checkAndApproveAllowance( + ctx context.Context, + erc20Contract *erc20.Erc20, + owner, spender common.Address, + price *big.Int, +) error { + // Check allowance + allowance, err := erc20Contract.Allowance(&bind.CallOpts{Context: ctx}, owner, spender) + if err != nil { + return fmt.Errorf("failed to get allowance: %w", err) + } + bs.logger.Infof("current allowance: %s", allowance.String()) + + // If allowance is less than the price, approve more tokens + if allowance.Cmp(price) < 0 { + bs.logger.Infof("Allowance insufficient. Initiating approval for the required amount...") + + // Approve the required amount + approveTx, err := erc20Contract.Approve(bs.transactOpts, spender, price) + if err != nil { + return fmt.Errorf("failed to approve token spending: %w", err) + } + + bs.logger.Infof("Approval transaction sent: %s", approveTx.Hash().Hex()) + + // Wait for the approval transaction to be mined + receipt, err := bind.WaitMined(ctx, bs.client, approveTx) + if err != nil { + return fmt.Errorf("failed to wait for approval transaction to be mined: %w", err) + } + + if receipt.Status != types.ReceiptStatusSuccessful { + return fmt.Errorf("approval transaction failed: %v", receipt) + } + + bs.logger.Info("Approval transaction mined successfully.") + } else { + bs.logger.Infof("Sufficient allowance available. Proceeding with the transaction...") + } + + return nil +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 00000000..62076e4c --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,39 @@ +package cache + +import ( + "github.com/ethereum/go-ethereum/common" + lru "github.com/hashicorp/golang-lru" +) + +type TokenData struct { + Contract *common.Address // Cached contract instance + Decimals uint8 // Token decimals +} + +type TokenCache struct { + cache *lru.Cache +} + +func NewTokenCache(size int) (*TokenCache, error) { + c, err := lru.New(size) + if err != nil { + return nil, err + } + return &TokenCache{cache: c}, nil +} + +func (tc *TokenCache) Get(contractAddress common.Address) (int32, bool) { + value, ok := tc.cache.Get(contractAddress.Hex()) + if !ok { + return 0, false + } + decimals, ok := value.(int32) + if !ok { + return 0, false + } + return decimals, true +} + +func (tc *TokenCache) Add(contractAddress common.Address, decimals int32) { + tc.cache.Add(contractAddress.Hex(), decimals) +}