From d2ad06ddfdee79f640574cf93b3ff1134d6b3617 Mon Sep 17 00:00:00 2001 From: evlekht Date: Wed, 2 Oct 2024 17:42:46 +0400 Subject: [PATCH] Add mint v1 support (#53) --- examples/booking/mintnbuy.go | 5 +- .../rpc/partner-plugin/handlers/mint_v1.go | 6 + internal/messaging/mint.go | 241 ++++++++++++ internal/messaging/mint_v1.go | 132 +++++++ internal/messaging/mint_v2.go | 140 +++++++ internal/messaging/response_handler.go | 351 +----------------- pkg/booking/booking.go | 9 +- 7 files changed, 544 insertions(+), 340 deletions(-) create mode 100644 internal/messaging/mint.go create mode 100644 internal/messaging/mint_v1.go create mode 100644 internal/messaging/mint_v2.go diff --git a/examples/booking/mintnbuy.go b/examples/booking/mintnbuy.go index fe5b91f1..1c84a138 100644 --- a/examples/booking/mintnbuy.go +++ b/examples/booking/mintnbuy.go @@ -78,6 +78,9 @@ func main() { } bt, err := bookingtoken.NewBookingtoken(common.HexToAddress("0xe55E387F5474a012D1b048155E25ea78C7DBfBBC"), client) + if err != nil { + sugar.Fatalf("Failed to create BookingToken contract binding: %v", err) + } // token uri tokenURI := "data:application/json;base64,eyJuYW1lIjoiYm90IGNtYWNjb3VudCBwa2cgYm9va2luZyB0b2tlbiB0ZXN0In0K" @@ -193,7 +196,7 @@ func main() { switch price.Currency.Currency.(type) { case *typesv2.Currency_NativeToken: - bigIntPrice, err = bs.ConvertPriceToBigInt(price, int32(18)) // CAM uses 18 decimals + bigIntPrice, 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 diff --git a/examples/rpc/partner-plugin/handlers/mint_v1.go b/examples/rpc/partner-plugin/handlers/mint_v1.go index c62329ac..2ee6a9f9 100644 --- a/examples/rpc/partner-plugin/handlers/mint_v1.go +++ b/examples/rpc/partner-plugin/handlers/mint_v1.go @@ -11,6 +11,7 @@ import ( typesv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v1" "github.com/chain4travel/camino-messenger-bot/internal/metadata" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -37,6 +38,11 @@ func (*MintServiceV1Server) Mint(ctx context.Context, _ *bookv1.MintRequest) (*b Price: &typesv1.Price{ Value: "1", Decimals: 9, + Currency: &typesv1.Currency{ + Currency: &typesv1.Currency_NativeToken{ + NativeToken: &emptypb.Empty{}, + }, + }, }, } diff --git a/internal/messaging/mint.go b/internal/messaging/mint.go new file mode 100644 index 00000000..a2f14d3b --- /dev/null +++ b/internal/messaging/mint.go @@ -0,0 +1,241 @@ +package messaging + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "time" + + notificationv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/notification/v1" + typesv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v1" + "github.com/chain4travel/camino-messenger-contracts/go/contracts/bookingtoken" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + ethTypes "github.com/ethereum/go-ethereum/core/types" + "google.golang.org/grpc" + grpc_metadata "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Mints a BookingToken with the supplier private key and reserves it for the buyer address +// For testing you can use this uri: "data:application/json;base64,eyJuYW1lIjoiQ2FtaW5vIE1lc3NlbmdlciBCb29raW5nVG9rZW4gVGVzdCJ9Cg==" +func (h *evmResponseHandler) mint( + ctx context.Context, + reservedFor common.Address, + uri string, + expiration *big.Int, + price *big.Int, + paymentToken common.Address, +) (string, *big.Int, error) { + // TODO: + // (in booking package) + // define paymentToken from currency + // if TokenCurrency get paymentToken contract and call decimals() + // calculate the price in big int without loosing precision + + tx, err := h.bookingService.MintBookingToken( + reservedFor, + uri, + expiration, + price, + paymentToken) + if err != nil { + return "", nil, err + } + + // Wait for transaction to be mined + receipt, err := bind.WaitMined(ctx, h.ethClient, tx) + if err != nil { + return "", nil, err + } + + tokenID := big.NewInt(0) + + for _, mLog := range receipt.Logs { + event, err := h.bookingToken.ParseTokenReserved(*mLog) + if err == nil { + tokenID = event.TokenId + h.logger.Infof("[TokenReserved] TokenID: %s ReservedFor: %s Price: %s, PaymentToken: %s", event.TokenId, event.ReservedFor, event.Price, event.PaymentToken) + } + } + + return tx.Hash().Hex(), tokenID, nil +} + +// TODO @VjeraTurk code that creates and handles context should be improved, since its not doing job in separate goroutine, +// Buys a token with the buyer private key. Token must be reserved for the buyer address. +func (h *evmResponseHandler) buy(ctx context.Context, tokenID *big.Int) (string, error) { + tx, err := h.bookingService.BuyBookingToken(tokenID) + if err != nil { + return "", err + } + + receipt, err := h.waitTransaction(ctx, tx) + if err != nil { + return "", err + } + if receipt.Status != ethTypes.ReceiptStatusSuccessful { + return "", fmt.Errorf("transaction failed: %v", receipt) + } + + h.logger.Infof("Transaction sent!\nTransaction hash: %s\n", tx.Hash().Hex()) + + return tx.Hash().Hex(), nil +} + +func (h *evmResponseHandler) onBookingTokenMint(tokenID *big.Int, mintID *typesv1.UUID, buyableUntil time.Time) { + notificationClient := h.serviceRegistry.NotificationClient() + expirationTimer := &time.Timer{} + + unsubscribeTokenBought, err := h.evmEventListener.RegisterTokenBoughtHandler( + h.bookingTokenAddress, + []*big.Int{tokenID}, + nil, + func(e any) { + expirationTimer.Stop() + h.logger.Infof("Token bought event received for token %s", tokenID.String()) + event := e.(*bookingtoken.BookingtokenTokenBought) + + if _, err := notificationClient.TokenBoughtNotification( + context.Background(), + ¬ificationv1.TokenBought{ + TokenId: tokenID.Uint64(), + TxId: event.Raw.TxHash.Hex(), + MintId: mintID, + }, + grpc.Header(&grpc_metadata.MD{}), + ); err != nil { + h.logger.Errorf("error calling partner plugin TokenBoughtNotification service: %v", err) + } + }, + ) + if err != nil { + h.logger.Errorf("failed to register handler: %v", err) + // TODO @evlekht send some notification to partner plugin + return + } + + expirationTimer = time.AfterFunc(time.Until(buyableUntil), func() { + unsubscribeTokenBought() + h.logger.Infof("Token %s expired", tokenID.String()) + + if _, err := notificationClient.TokenExpiredNotification( + context.Background(), + ¬ificationv1.TokenExpired{ + TokenId: tokenID.Uint64(), + MintId: mintID, + }, + grpc.Header(&grpc_metadata.MD{}), + ); err != nil { + h.logger.Errorf("error calling partner plugin TokenExpiredNotification service: %v", err) + } + }) +} + +// TODO @evlekht check if those structs are needed as exported here, otherwise make them private or move to another pkg +type hotelAtrribute struct { + TraitType string `json:"trait_type"` + Value string `json:"value"` +} + +type hotelJSON struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Date string `json:"date,omitempty"` + ExternalURL string `json:"external_url,omitempty"` + Image string `json:"image,omitempty"` + Attributes []hotelAtrribute `json:"attributes,omitempty"` +} + +// Generates a token data URI from a MintResponse object. Returns jsonPlain and a +// data URI with base64 encoded json data. +// +// TODO: @havan: We need decide what data needs to be in the tokenURI JSON and add +// those fields to the MintResponse. These will be shown in the UI of wallets, +// explorers etc. +func createTokenURIforMintResponse(mintID, bookingReference string) (string, string, error) { + // TODO: What should we use for a token name? This will be shown in the UI of wallets, explorers etc. + name := "CM Booking Token" + + // TODO: What should we use for a token description? This will be shown in the UI of wallets, explorers etc. + description := "This NFT represents the booking with the specified attributes." + + // Dummy data + date := "2024-09-27" + + externalURL := "https://camino.network" + + // Placeholder Image + image := "https://camino.network/static/images/N9IkxmG-Sg-1800.webp" + + attributes := []hotelAtrribute{ + { + TraitType: "Mint ID", + Value: mintID, + }, + { + TraitType: "Reference", + Value: bookingReference, + }, + } + + jsonPlain, jsonEncoded, err := generateAndEncodeJSON( + name, + description, + date, + externalURL, + image, + attributes, + ) + if err != nil { + return "", "", err + } + + // Add data URI scheme + tokenURI := "data:application/json;base64," + jsonEncoded + + return jsonPlain, tokenURI, nil +} + +func generateAndEncodeJSON(name, description, date, externalURL, image string, attributes []hotelAtrribute) (string, string, error) { + hotel := hotelJSON{ + Name: name, + Description: description, + Date: date, + ExternalURL: externalURL, + Image: image, + Attributes: attributes, + } + + jsonData, err := json.Marshal(hotel) + if err != nil { + return "", "", err + } + + encoded := base64.StdEncoding.EncodeToString(jsonData) + return string(jsonData), encoded, nil +} + +func verifyAndFixBuyableUntil(buyableUntil *timestamppb.Timestamp, currentTime time.Time) (*timestamppb.Timestamp, error) { + switch { + case buyableUntil == nil || buyableUntil.Seconds == 0: + // BuyableUntil not set + return timestamppb.New(currentTime.Add(buyableUntilDurationDefault)), nil + + case buyableUntil.Seconds < timestamppb.New(currentTime).Seconds: + // BuyableUntil in the past + return nil, fmt.Errorf("refused to mint token - BuyableUntil in the past: %v", buyableUntil) + + case buyableUntil.Seconds < timestamppb.New(currentTime.Add(buyableUntilDurationMinimal)).Seconds: + // BuyableUntil too early + return timestamppb.New(currentTime.Add(buyableUntilDurationMinimal)), nil + + case buyableUntil.Seconds > timestamppb.New(currentTime.Add(buyableUntilDurationMaximal)).Seconds: + // BuyableUntil too late + return timestamppb.New(currentTime.Add(buyableUntilDurationMaximal)), nil + } + + return buyableUntil, nil +} diff --git a/internal/messaging/mint_v1.go b/internal/messaging/mint_v1.go new file mode 100644 index 00000000..73ec212a --- /dev/null +++ b/internal/messaging/mint_v1.go @@ -0,0 +1,132 @@ +package messaging + +import ( + "context" + "fmt" + "math/big" + "time" + + 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/common" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func (h *evmResponseHandler) handleMintResponseV1(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage) bool { + mintResp, ok := response.(*bookv1.MintResponse) + if !ok { + return false + } + mintReq, ok := request.(*bookv1.MintRequest) + if !ok { + return false + } + if mintResp.Header == nil { + mintResp.Header = &typesv1.ResponseHeader{} + } + + // TODO @evlekht ensure that mintReq.BuyerAddress is c-chain address format, not x/p/t chain + buyerAddress := common.HexToAddress(mintReq.BuyerAddress) + + // Get a Token URI for the token. + jsonPlain, tokenURI, err := createTokenURIforMintResponse( + mintResp.MintId.Value, + mintReq.BookingReference, + ) + if err != nil { + errMsg := fmt.Sprintf("error creating token URI: %v", err) + h.logger.Debugf(errMsg) // TODO: @VjeraTurk change to Error after we stop using mocked uri data + h.AddErrorToResponseHeader(response, errMsg) + return true + } + + h.logger.Debugf("Token URI JSON: %s\n", jsonPlain) + + mintResp.BuyableUntil, err = verifyAndFixBuyableUntil(mintResp.BuyableUntil, time.Now()) + if err != nil { + h.logger.Error(err) + h.AddErrorToResponseHeader(response, err.Error()) + } + + price, paymentToken, err := h.getPriceAndTokenV1(ctx, mintResp.Price) + if err != nil { + errMessage := fmt.Sprintf("error minting NFT: %v", err) + h.logger.Errorf(errMessage) + h.AddErrorToResponseHeader(response, errMessage) + return true + } + + // MINT TOKEN + txID, tokenID, err := h.mint( + ctx, + buyerAddress, + tokenURI, + big.NewInt(mintResp.BuyableUntil.Seconds), + price, + paymentToken, + ) + if err != nil { + errMessage := fmt.Sprintf("error minting NFT: %v", err) + h.logger.Errorf(errMessage) + h.AddErrorToResponseHeader(response, errMessage) + return true + } + + h.logger.Infof("NFT minted with txID: %s\n", txID) + + h.onBookingTokenMint(tokenID, mintResp.MintId, mintResp.BuyableUntil.AsTime()) + + mintResp.Header.Status = typesv1.StatusType_STATUS_TYPE_SUCCESS + mintResp.BookingToken = &typesv1.BookingToken{TokenId: int32(tokenID.Int64())} //nolint:gosec + mintResp.MintTransactionId = txID + return false +} + +func (h *evmResponseHandler) handleMintRequestV1(ctx context.Context, response protoreflect.ProtoMessage) bool { + mintResp, ok := response.(*bookv1.MintResponse) + if !ok { + return false + } + if mintResp.Header == nil { + mintResp.Header = &typesv1.ResponseHeader{} + } + if mintResp.MintTransactionId == "" { + h.AddErrorToResponseHeader(response, "missing mint transaction id") + return true + } + + value64 := uint64(mintResp.BookingToken.TokenId) + tokenID := new(big.Int).SetUint64(value64) + + txID, err := h.buy(ctx, tokenID) + if err != nil { + errMessage := fmt.Sprintf("error buying NFT: %v", err) + h.logger.Errorf(errMessage) + h.AddErrorToResponseHeader(response, errMessage) + return true + } + + h.logger.Infof("Bought NFT (txID=%s) with ID: %s\n", txID, mintResp.MintTransactionId) + mintResp.BuyTransactionId = txID + return false +} + +func (h *evmResponseHandler) getPriceAndTokenV1(_ context.Context, price *typesv1.Price) (*big.Int, common.Address, error) { + priceBigInt := big.NewInt(0) + paymentToken := zeroAddress + switch 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") + case *typesv1.Currency_IsoCurrency: + // For IsoCurrency, keep price as 0 and paymentToken as zeroAddress + } + return priceBigInt, paymentToken, nil +} diff --git a/internal/messaging/mint_v2.go b/internal/messaging/mint_v2.go new file mode 100644 index 00000000..e86e1855 --- /dev/null +++ b/internal/messaging/mint_v2.go @@ -0,0 +1,140 @@ +package messaging + +import ( + "context" + "fmt" + "math/big" + "time" + + 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/ethereum/go-ethereum/common" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func (h *evmResponseHandler) handleMintResponseV2(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage) bool { + mintResp, ok := response.(*bookv2.MintResponse) + if !ok { + return false + } + mintReq, ok := request.(*bookv2.MintRequest) + if !ok { + return false + } + if mintResp.Header == nil { + mintResp.Header = &typesv1.ResponseHeader{} + } + + var err error + + // TODO: @VjeraTurk check if CMAccount exists + // TODO @evlekht ensure that mintReq.BuyerAddress is c-chain address format, not x/p/t chain + buyerAddress := common.HexToAddress(mintReq.BuyerAddress) + + // TODO@ do we need to update token uri in response? + tokenURI := mintResp.BookingTokenUri + if tokenURI == "" { + // Get a Token URI for the token. + var jsonPlain string + jsonPlain, tokenURI, err = createTokenURIforMintResponse( + mintResp.MintId.Value, + mintReq.BookingReference, + ) + if err != nil { + errMsg := fmt.Sprintf("Failed to mint token: failed to generate tokenURI: %s", err) + h.logger.Error(errMsg) + h.AddErrorToResponseHeader(response, errMsg) + return true + } + h.logger.Debugf("Token URI JSON: %s\n", jsonPlain) + } + h.logger.Debugf("Token URI: %s\n", tokenURI) + + mintResp.BuyableUntil, err = verifyAndFixBuyableUntil(mintResp.BuyableUntil, time.Now()) + if err != nil { + h.logger.Error(err) + h.AddErrorToResponseHeader(response, err.Error()) + } + + price, paymentToken, err := h.getPriceAndTokenV2(ctx, mintResp.Price) + if err != nil { + errMessage := fmt.Sprintf("error minting NFT: %v", err) + h.logger.Errorf(errMessage) + h.AddErrorToResponseHeader(response, errMessage) + return true + } + + // MINT TOKEN + txID, tokenID, err := h.mint( + ctx, + buyerAddress, + tokenURI, + big.NewInt(mintResp.BuyableUntil.Seconds), + price, + paymentToken, + ) + if err != nil { + errMessage := fmt.Sprintf("error minting NFT: %v", err) + h.logger.Errorf(errMessage) + h.AddErrorToResponseHeader(response, errMessage) + return true + } + + h.logger.Infof("NFT minted with txID: %s\n", txID) + + h.onBookingTokenMint(tokenID, mintResp.MintId, mintResp.BuyableUntil.AsTime()) + + mintResp.Header.Status = typesv1.StatusType_STATUS_TYPE_SUCCESS + mintResp.BookingTokenId = tokenID.Uint64() + mintResp.MintTransactionId = txID + return false +} + +func (h *evmResponseHandler) handleMintRequestV2(ctx context.Context, response protoreflect.ProtoMessage) bool { + mintResp, ok := response.(*bookv2.MintResponse) + if !ok { + return false + } + if mintResp.Header == nil { + mintResp.Header = &typesv1.ResponseHeader{} + } + if mintResp.MintTransactionId == "" { + h.AddErrorToResponseHeader(response, "missing mint transaction id") + return true + } + + tokenID := new(big.Int).SetUint64(mintResp.BookingTokenId) + + txID, err := h.buy(ctx, tokenID) + if err != nil { + errMessage := fmt.Sprintf("error buying NFT: %v", err) + h.logger.Errorf(errMessage) + h.AddErrorToResponseHeader(response, errMessage) + return true + } + + h.logger.Infof("Bought NFT (txID=%s) with ID: %s\n", txID, mintResp.MintTransactionId) + mintResp.BuyTransactionId = txID + return false +} + +func (h *evmResponseHandler) getPriceAndTokenV2(_ context.Context, price *typesv2.Price) (*big.Int, common.Address, error) { + priceBigInt := big.NewInt(0) + paymentToken := zeroAddress + switch 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") + case *typesv2.Currency_IsoCurrency: + // For IsoCurrency, keep price as 0 and paymentToken as zeroAddress + } + return priceBigInt, paymentToken, nil +} diff --git a/internal/messaging/response_handler.go b/internal/messaging/response_handler.go index 8fc80521..f497193d 100644 --- a/internal/messaging/response_handler.go +++ b/internal/messaging/response_handler.go @@ -6,22 +6,15 @@ package messaging import ( "context" "crypto/ecdsa" - "encoding/base64" - "encoding/json" "fmt" "log" - "math/big" "time" + bookv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/book/v1" bookv2 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/book/v2" - notificationv1 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/services/notification/v1" 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" - grpc "google.golang.org/grpc" - grpc_metadata "google.golang.org/grpc/metadata" "google.golang.org/protobuf/reflect/protoreflect" - "google.golang.org/protobuf/types/known/timestamppb" "github.com/ethereum/go-ethereum/accounts/abi/bind" ethTypes "github.com/ethereum/go-ethereum/core/types" @@ -106,13 +99,21 @@ type evmResponseHandler struct { func (h *evmResponseHandler) HandleResponse(ctx context.Context, msgType types.MessageType, request protoreflect.ProtoMessage, response protoreflect.ProtoMessage) { switch msgType { + case generated.MintServiceV1Request: // distributor will post-process a mint request to buy the returned NFT + if h.handleMintRequestV1(ctx, response) { + 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) { + 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 if h.handleMintRequestV2(ctx, response) { - return + 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) { - return + return // TODO @evlekht we don't need this if true/false then do nothing } } } @@ -125,199 +126,14 @@ func (h *evmResponseHandler) HandleRequest(_ context.Context, msgType types.Mess return nil } mintReq.BuyerAddress = h.cmAccountAddress.Hex() - } - return nil -} - -func (h *evmResponseHandler) handleMintResponseV2(ctx context.Context, response protoreflect.ProtoMessage, request protoreflect.ProtoMessage) bool { - mintResp, ok := response.(*bookv2.MintResponse) - if !ok { - return false - } - mintReq, ok := request.(*bookv2.MintRequest) - if !ok { - return false - } - if mintResp.Header == nil { - mintResp.Header = &typesv1.ResponseHeader{} - } - - // TODO: @VjeraTurk check if CMAccount exists - // TODO @evlekht ensure that mintReq.BuyerAddress is c-chain address format, not x/p/t chain - buyerAddress := common.HexToAddress(mintReq.BuyerAddress) - - tokenURI := mintResp.BookingTokenUri - - if tokenURI == "" { - // Get a Token URI for the token. - var jsonPlain string - jsonPlain, tokenURI, _ = createTokenURIforMintResponse(mintResp) - h.logger.Debugf("Token URI JSON: %s\n", jsonPlain) - } else { - h.logger.Debugf("Token URI: %s\n", tokenURI) - } - - currentTime := time.Now() - - switch { - case mintResp.BuyableUntil == nil || mintResp.BuyableUntil.Seconds == 0: - // BuyableUntil not set - mintResp.BuyableUntil = timestamppb.New(currentTime.Add(buyableUntilDurationDefault)) - - case mintResp.BuyableUntil.Seconds < timestamppb.New(currentTime).Seconds: - // BuyableUntil in the past - errMsg := fmt.Sprintf("Refused to mint token - BuyableUntil in the past: %v", mintResp.BuyableUntil) - h.AddErrorToResponseHeader(response, errMsg) - return true - - case mintResp.BuyableUntil.Seconds < timestamppb.New(currentTime.Add(buyableUntilDurationMinimal)).Seconds: - // BuyableUntil too early - mintResp.BuyableUntil = timestamppb.New(currentTime.Add(buyableUntilDurationMinimal)) - - case mintResp.BuyableUntil.Seconds > timestamppb.New(currentTime.Add(buyableUntilDurationMaximal)).Seconds: - // BuyableUntil too late - mintResp.BuyableUntil = timestamppb.New(currentTime.Add(buyableUntilDurationMaximal)) - } - - // MINT TOKEN - txID, tokenID, err := h.mint( - ctx, - buyerAddress, - tokenURI, - big.NewInt(mintResp.BuyableUntil.Seconds), - mintResp.Price, - ) - if err != nil { - errMessage := fmt.Sprintf("error minting NFT: %v", err) - h.logger.Errorf(errMessage) - h.AddErrorToResponseHeader(response, errMessage) - return true - } - - h.logger.Infof("NFT minted with txID: %s\n", txID) - - h.onBookingTokenMint(tokenID, mintResp.MintId, mintResp.BuyableUntil.AsTime()) - - // Header is of typev1 - mintResp.Header.Status = typesv1.StatusType_STATUS_TYPE_SUCCESS - // Disable Linter: This code will be removed with the new mint logic and protocol - mintResp.BookingTokenId = tokenID.Uint64() - mintResp.MintTransactionId = txID - return false -} - -func (h *evmResponseHandler) handleMintRequestV2(ctx context.Context, response protoreflect.ProtoMessage) bool { - mintResp, ok := response.(*bookv2.MintResponse) - if !ok { - return false - } - if mintResp.Header == nil { - mintResp.Header = &typesv1.ResponseHeader{} - } - if mintResp.MintTransactionId == "" { - h.AddErrorToResponseHeader(response, "missing mint transaction id") - return true - } - - tokenID := new(big.Int).SetUint64(mintResp.BookingTokenId) - - txID, err := h.buy(ctx, tokenID) - if err != nil { - errMessage := fmt.Sprintf("error buying NFT: %v", err) - h.logger.Errorf(errMessage) - h.AddErrorToResponseHeader(response, errMessage) - return true - } - - h.logger.Infof("Bought NFT (txID=%s) with ID: %s\n", txID, mintResp.MintTransactionId) - mintResp.BuyTransactionId = txID - return false -} - -// Mints a BookingToken with the supplier private key and reserves it for the buyer address -// For testing you can use this uri: "data:application/json;base64,eyJuYW1lIjoiQ2FtaW5vIE1lc3NlbmdlciBCb29raW5nVG9rZW4gVGVzdCJ9Cg==" -func (h *evmResponseHandler) mint( - ctx context.Context, - reservedFor common.Address, - uri string, - expiration *big.Int, - price *typesv2.Price, -) (string, *big.Int, error) { - bigIntPrice := big.NewInt(0) - paymentToken := zeroAddress - var err error - - // TODO: - // (in booking package) - // define paymentToken from currency - // if TokenCurrency get paymentToken contract and call decimals() - // calculate the price in big int without loosing precision - - switch price.Currency.Currency.(type) { - case *typesv2.Currency_NativeToken: - bigIntPrice, err = h.bookingService.ConvertPriceToBigInt(price, int32(18)) // CAM uses 18 decimals - if err != nil { - return "", nil, err - } - paymentToken = zeroAddress - case *typesv2.Currency_TokenCurrency: - // Add logic to handle TokenCurrency - // if contract address is zeroAddress, then it is native token - return "", nil, fmt.Errorf("TokenCurrency not supported yet") - case *typesv2.Currency_IsoCurrency: - // For IsoCurrency, keep price as 0 and paymentToken as zeroAddress - bigIntPrice = big.NewInt(0) - paymentToken = zeroAddress - } - - tx, err := h.bookingService.MintBookingToken( - reservedFor, - uri, - expiration, - bigIntPrice, - paymentToken) - if err != nil { - return "", nil, err - } - - // Wait for transaction to be mined - receipt, err := bind.WaitMined(ctx, h.ethClient, tx) - if err != nil { - return "", nil, err - } - - tokenID := big.NewInt(0) - - for _, mLog := range receipt.Logs { - event, err := h.bookingToken.ParseTokenReserved(*mLog) - if err == nil { - tokenID = event.TokenId - h.logger.Infof("[TokenReserved] TokenID: %s ReservedFor: %s Price: %s, PaymentToken: %s", event.TokenId, event.ReservedFor, event.Price, event.PaymentToken) + case generated.MintServiceV1Request: + mintReq, ok := request.(*bookv1.MintRequest) + if !ok { + return nil } + mintReq.BuyerAddress = h.cmAccountAddress.Hex() } - - return tx.Hash().Hex(), tokenID, nil -} - -// TODO @VjeraTurk code that creates and handles context should be improved, since its not doing job in separate goroutine, -// Buys a token with the buyer private key. Token must be reserved for the buyer address. -func (h *evmResponseHandler) buy(ctx context.Context, tokenID *big.Int) (string, error) { - tx, err := h.bookingService.BuyBookingToken(tokenID) - if err != nil { - return "", err - } - - receipt, err := h.waitTransaction(ctx, tx) - if err != nil { - return "", err - } - if receipt.Status != ethTypes.ReceiptStatusSuccessful { - return "", fmt.Errorf("transaction failed: %v", receipt) - } - - h.logger.Infof("Transaction sent!\nTransaction hash: %s\n", tx.Hash().Hex()) - - return tx.Hash().Hex(), nil + return nil } // Waits for a transaction to be mined @@ -338,139 +154,6 @@ func (h *evmResponseHandler) waitTransaction(ctx context.Context, tx *ethTypes.T return receipt, nil } -func (h *evmResponseHandler) onBookingTokenMint(tokenID *big.Int, mintID *typesv1.UUID, buyableUntil time.Time) { - notificationClient := h.serviceRegistry.NotificationClient() - expirationTimer := &time.Timer{} - - unsubscribeTokenBought, err := h.evmEventListener.RegisterTokenBoughtHandler( - h.bookingTokenAddress, - []*big.Int{tokenID}, - nil, - func(e any) { - expirationTimer.Stop() - h.logger.Infof("Token bought event received for token %s", tokenID.String()) - event := e.(*bookingtoken.BookingtokenTokenBought) - - if _, err := notificationClient.TokenBoughtNotification( - context.Background(), - ¬ificationv1.TokenBought{ - TokenId: tokenID.Uint64(), - TxId: event.Raw.TxHash.Hex(), - MintId: mintID, - }, - grpc.Header(&grpc_metadata.MD{}), - ); err != nil { - h.logger.Errorf("error calling partner plugin TokenBoughtNotification service: %v", err) - } - }, - ) - if err != nil { - h.logger.Errorf("failed to register handler: %v", err) - // TODO @evlekht send some notification to partner plugin - return - } - - expirationTimer = time.AfterFunc(time.Until(buyableUntil), func() { - unsubscribeTokenBought() - h.logger.Infof("Token %s expired", tokenID.String()) - - if _, err := notificationClient.TokenExpiredNotification( - context.Background(), - ¬ificationv1.TokenExpired{ - TokenId: tokenID.Uint64(), - MintId: mintID, - }, - grpc.Header(&grpc_metadata.MD{}), - ); err != nil { - h.logger.Errorf("error calling partner plugin TokenExpiredNotification service: %v", err) - } - }) -} - -// TODO @evlekht check if those structs are needed as exported here, otherwise make them private or move to another pkg -type Attribute struct { - TraitType string `json:"trait_type"` - Value string `json:"value"` -} - -type HotelJSON struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Date string `json:"date,omitempty"` - ExternalURL string `json:"external_url,omitempty"` - Image string `json:"image,omitempty"` - Attributes []Attribute `json:"attributes,omitempty"` -} - -func generateAndEncodeJSON(name, description, date, externalURL, image string, attributes []Attribute) (string, string, error) { - hotel := HotelJSON{ - Name: name, - Description: description, - Date: date, - ExternalURL: externalURL, - Image: image, - Attributes: attributes, - } - - jsonData, err := json.Marshal(hotel) - if err != nil { - return "", "", err - } - - encoded := base64.StdEncoding.EncodeToString(jsonData) - return string(jsonData), encoded, nil -} - -// Generates a token data URI from a MintResponse object. Returns jsonPlain and a -// data URI with base64 encoded json data. -// -// TODO: @havan: We need decide what data needs to be in the tokenURI JSON and add -// those fields to the MintResponse. These will be shown in the UI of wallets, -// explorers etc. -func createTokenURIforMintResponse(mintResponse *bookv2.MintResponse) (string, string, error) { - // TODO: What should we use for a token name? This will be shown in the UI of wallets, explorers etc. - name := "CM Booking Token" - - // TODO: What should we use for a token description? This will be shown in the UI of wallets, explorers etc. - description := "This NFT represents the booking with the specified attributes." - - // Dummy data - date := "2024-09-27" - - externalURL := "https://camino.network" - - // Placeholder Image - image := "https://camino.network/static/images/N9IkxmG-Sg-1800.webp" - - attributes := []Attribute{ - { - TraitType: "Mint ID", - Value: mintResponse.GetMintId().Value, - }, - { - TraitType: "Reference", - Value: mintResponse.GetProviderBookingReference(), - }, - } - - jsonPlain, jsonEncoded, err := generateAndEncodeJSON( - name, - description, - date, - externalURL, - image, - attributes, - ) - if err != nil { - return "", "", err - } - - // Add data URI scheme - tokenURI := "data:application/json;base64," + jsonEncoded - - return jsonPlain, tokenURI, nil -} - func (h *evmResponseHandler) AddErrorToResponseHeader(response protoreflect.ProtoMessage, errMessage string) { headerFieldDescriptor := response.ProtoReflect().Descriptor().Fields().ByName("header") headerReflectValue := response.ProtoReflect().Get(headerFieldDescriptor) diff --git a/pkg/booking/booking.go b/pkg/booking/booking.go index be7d2c57..aade8e1b 100644 --- a/pkg/booking/booking.go +++ b/pkg/booking/booking.go @@ -7,7 +7,6 @@ import ( "math/big" "strings" - typesv2 "buf.build/gen/go/chain4travel/camino-messenger-protocol/protocolbuffers/go/cmp/types/v2" "github.com/chain4travel/camino-messenger-contracts/go/contracts/cmaccount" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -129,16 +128,16 @@ func (bs *Service) BuyBookingToken( } // convertPriceToBigInt converts the price to its integer representation -func (bs *Service) ConvertPriceToBigInt(price *typesv2.Price, totalDecimals int32) (*big.Int, error) { +func (bs *Service) ConvertPriceToBigInt(value string, decimals int32, totalDecimals int32) (*big.Int, error) { // Convert the value string to a big.Int valueBigInt := new(big.Int) - _, ok := valueBigInt.SetString(price.Value, 10) + _, ok := valueBigInt.SetString(value, 10) if !ok { - return nil, fmt.Errorf("failed to convert value to big.Int: %s", price.Value) + return nil, fmt.Errorf("failed to convert value to big.Int: %s", value) } // Calculate the multiplier as 10^(totalDecimals - price.Decimals) - multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(totalDecimals-price.Decimals)), nil) + multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(totalDecimals-decimals)), nil) // Multiply the value by the multiplier result := new(big.Int).Mul(valueBigInt, multiplier)