From 9c990145ec5e847569d36f29fde65e8ace3ae09a Mon Sep 17 00:00:00 2001 From: emmdim Date: Fri, 7 Jun 2024 15:01:53 +0200 Subject: [PATCH] Initial Stripe extension, that implements the standar stripe checkout process, assuming an embedded form, and a given stripe price id. The min and max number of tokes is defined (should be paremtrized) and just aflat rate is allowed. Adds the handlers: - `/createCheckoutSession/{referral}/{to}` - `/sessionStatus/{session_id}` - /webhook" and the following env vars: -STRIPEKEY -STRIPEPRICEID -STRIPEWEBHOOKSECRET -STRIPEMINQUANTITY -STRIPEMAXQUANTITY -STRIPEDEFAULTQUANTITY --- .env.example | 12 ++++ faucet.go | 30 ++++++++++ go.mod | 1 + go.sum | 3 + handlers.go | 121 ++++++++++++++++++++++++++++++++++++++ handlers_response.go | 2 + main.go | 42 ++++++++++++- storage/storage.go | 53 ++++++++++++++++- stripehandler/handler.go | 124 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 stripehandler/handler.go diff --git a/.env.example b/.env.example index 1cbb637..4334d78 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,18 @@ DB_TYPE=pebble BASE_ROUTE=/v2 # authentication types to use (comma separated). Available: open, oauth AUTH=open +# stripe secret key +STRIPE_KEY= +# stripe price id +STRIPE_PRICE_ID= +# min number of tokens +STRIPEMINQUANTITY= +# max number of tokens +STRIPEMAXQUANTITY= +# default number of tokens +STRIPEDEFAULTQUANTITY= +# stripe webhook secret +STRIPE_WEBHOOK_SECRET= RESTART=unless-stopped diff --git a/faucet.go b/faucet.go index 75174bb..61c0d09 100644 --- a/faucet.go +++ b/faucet.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/vocdoni/vocfaucet/storage" + "github.com/vocdoni/vocfaucet/stripehandler" "go.vocdoni.io/dvote/api" vfaucet "go.vocdoni.io/dvote/api/faucet" "go.vocdoni.io/dvote/crypto/ethereum" @@ -18,6 +19,8 @@ type faucet struct { authTypes map[string]uint64 waitPeriod time.Duration storage *storage.Storage + stripe *stripehandler.StripeProvider + domain string } // prepareFaucetPackage prepares a faucet package, including the signature, for the given address. @@ -46,3 +49,30 @@ func (f *faucet) prepareFaucetPackage(toAddr common.Address, authTypeName string FaucetPackage: fpackageBytes, }, nil } + +// prepareFaucetPackage prepares a faucet package, including the signature, for the given address. +// Returns the faucet package as a marshaled json byte array, ready to be sent to the user. +func (f *faucet) prepareFaucetPackageWithAmount(toAddr common.Address, amount uint64) (*vfaucet.FaucetResponse, error) { + // check if the auth type is supported + if amount <= 0 { + return nil, fmt.Errorf("invalid requested amount: %d", amount) + } + + // generate faucet package + fpackage, err := vochain.GenerateFaucetPackage(f.signer, toAddr, amount) + if err != nil { + return nil, api.ErrCantGenerateFaucetPkg.WithErr(err) + } + fpackageBytes, err := json.Marshal(vfaucet.FaucetPackage{ + FaucetPayload: fpackage.Payload, + Signature: fpackage.Signature, + }) + if err != nil { + return nil, err + } + // send response + return &vfaucet.FaucetResponse{ + Amount: fmt.Sprint(amount), + FaucetPackage: fpackageBytes, + }, nil +} diff --git a/go.mod b/go.mod index 3559c36..36e9102 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/ethereum/go-ethereum v1.13.4 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.16.0 + github.com/stripe/stripe-go/v78 v78.3.0 go.vocdoni.io/dvote v1.10.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index d6718b3..6e71849 100644 --- a/go.sum +++ b/go.sum @@ -1465,6 +1465,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stripe/stripe-go/v78 v78.3.0 h1:FYlKhJKZdZ/1vATbuIN4T107DeL7w9oV13IcPOEwyPQ= +github.com/stripe/stripe-go/v78 v78.3.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ= github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807/go.mod h1:7jxmlfBCDBXRzr0eAQJ48XC1hBu1np4CS5+cHEYfwpc= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= @@ -1801,6 +1803,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/handlers.go b/handlers.go index 24ecdce..3429a7d 100644 --- a/handlers.go +++ b/handlers.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "errors" "fmt" + "net/http" + "strconv" "github.com/ethereum/go-ethereum/common" "github.com/vocdoni/vocfaucet/aragondaohandler" @@ -24,6 +27,42 @@ func (f *faucet) registerHandlers(api *apirest.API) { log.Fatal(err) } + if err := api.RegisterMethod( + "/createCheckoutSession/{referral}/{to}", + "POST", + apirest.MethodAccessTypePublic, + f.createCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/createCheckoutSession/{referral}/{to}/{amount}", + "POST", + apirest.MethodAccessTypePublic, + f.createCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/sessionStatus/{session_id}", + "GET", + apirest.MethodAccessTypePublic, + f.retrieveCheckoutSession, + ); err != nil { + log.Fatal(err) + } + + if err := api.RegisterMethod( + "/webhook", + "POST", + apirest.MethodAccessTypePublic, + f.handleWebhook, + ); err != nil { + log.Fatal(err) + } + if f.authTypes[AuthTypeOpen] > 0 { if err := api.RegisterMethod( "/open/claim/{to}", @@ -67,6 +106,88 @@ func (f *faucet) registerHandlers(api *apirest.API) { } } +// createCheckoutSession creates a new Stripe Checkout session +func (f *faucet) createCheckoutSession(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { + to := ctx.URLParam("to") + referral := ctx.URLParam("referral") + defaultAmount := f.stripe.DefaultAmount + if amount := ctx.URLParam("amount"); amount != "" { + var err error + defaultAmount, err = strconv.ParseInt(amount, 10, 64) + if err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrIncorrectParams) + } + } + s, err := f.stripe.CreateCheckoutSession(defaultAmount, to, referral) + if err != nil { + errReason := fmt.Sprintf("session.New: %v", err) + return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), CodeErrProviderError) + // + } + + data := &struct { + ClientSecret string `json:"clientSecret"` + }{ + ClientSecret: s.ClientSecret, + } + return ctx.Send(new(HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) +} + +func (f *faucet) retrieveCheckoutSession(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { + sessionId := ctx.URLParam("session_id") + status, err := f.stripe.RetrieveCheckoutSession(sessionId) + if err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrProviderError) + } + toFund, err := f.storage.GetPendingStripeSession(sessionId) + if err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrInternalError) + } + if toFund { + data, err := f.processPaymentTransfer(status.Quantity, status.Recipient) + if err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrInternalError) + } + if err := f.storage.RemovePendingStripeSession(sessionId); err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), CodeErrInternalError) + } + status.FaucetPackage = data + return ctx.Send(new(HandlerResponse).Set(status).MustMarshall(), apirest.HTTPstatusOK) + } + return ctx.Send(new(HandlerResponse).Set(status).MustMarshall(), apirest.HTTPstatusOK) + +} + +func (f *faucet) handleWebhook(apiData *apirest.APIdata, ctx *httprouter.HTTPContext) error { + sig := ctx.Request.Header.Get("Stripe-Signature") + // Pass the request body and Stripe-Signature header to ConstructEvent, along with the webhook signing key + sessionId, err := f.stripe.HandleWebhook(apiData, sig) + if err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), http.StatusBadRequest) + } + err = f.storage.AddPendingStripeSession(sessionId) + if err != nil { + return ctx.Send(new(HandlerResponse).SetError(err.Error()).MustMarshall(), http.StatusBadRequest) + } + return ctx.Send([]byte("success"), http.StatusOK) +} + +func (f *faucet) processPaymentTransfer(amount int64, to string) ([]byte, error) { + if amount == 0 { + return nil, errors.New("invalid requested amount") + } + addr, err := stringToAddress(to) + if err != nil { + return nil, err + } + data, err := f.prepareFaucetPackageWithAmount(addr, uint64(amount)) + if err != nil { + return nil, err + } + + return data.FaucetPackage, nil +} + // Returns the list of supported auth types func (f *faucet) authTypesHandler(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { data := &AuthTypes{ diff --git a/handlers_response.go b/handlers_response.go index 671a2a8..0421cb3 100644 --- a/handlers_response.go +++ b/handlers_response.go @@ -19,6 +19,8 @@ const ( CodeErrIncorrectParams = 408 CodeErrInternalError = 409 ReasonErrAragonDaoAddress = "could not find the signer address in any Aragon DAO" + CodeErrProviderError = 410 + ReasonErrProviderError = "error obtaining the oAuthToken" ) // HandlerResponse is the response format for the Handlers diff --git a/main.go b/main.go index 9a92c26..dfb44c9 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( flag "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/vocdoni/vocfaucet/storage" + "github.com/vocdoni/vocfaucet/stripehandler" "go.vocdoni.io/dvote/crypto/ethereum" "go.vocdoni.io/dvote/db" "go.vocdoni.io/dvote/httprouter" @@ -38,6 +39,12 @@ func main() { flag.String("amounts", "100", "tokens to send per request (comma separated), the order must match the auth types") flag.Duration("waitPeriod", 1*time.Hour, "wait period between requests for the same user") flag.StringP("dbType", "t", db.TypePebble, fmt.Sprintf("key-value db type [%s,%s,%s]", db.TypePebble, db.TypeLevelDB, db.TypeMongo)) + flag.String("stripeKey", "", "stripe secret key") + flag.String("stripePriceId", "", "stripe price id") + flag.Int64("stripeMinQuantity", 100, "stripe min number of tokens") + flag.Int64("stripeMaxQuantity", 100000, "stripe max number of tokens") + flag.Int64("stripeDefaultQuantity", 100, "stripe default number of tokens") + flag.String("stripeWebhookSecret", "", "stripe webhook secret key") flag.Parse() // Setting up viper @@ -87,6 +94,24 @@ func main() { if err := viper.BindPFlag("dbType", flag.Lookup("dbType")); err != nil { panic(err) } + if err := viper.BindPFlag("stripeKey", flag.Lookup("stripeKey")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripePriceId", flag.Lookup("stripePriceId")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeMinQuantity", flag.Lookup("stripeMinQuantity")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeMaxQuantity", flag.Lookup("stripeMaxQuantity")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeDefaultQuantity", flag.Lookup("stripeDefaultQuantity")); err != nil { + panic(err) + } + if err := viper.BindPFlag("stripeWebhookSecret", flag.Lookup("stripeWebhookSecret")); err != nil { + panic(err) + } // check if config file exists _, err := os.Stat(path.Join(dataDir, "faucet.yml")) @@ -122,8 +147,15 @@ func main() { privKey := viper.GetString("privKey") auth := viper.GetString("auth") amounts := viper.GetString("amounts") + waitPeriod := viper.GetDuration("waitPeriod") dbType := viper.GetString("dbType") + stripeKey := viper.GetString("stripeKey") + stripePriceId := viper.GetString("stripePriceId") + stripeMinQuantity := viper.GetInt64("stripeMinQuantity") + stripeMaxQuantity := viper.GetInt64("stripeMaxQuantity") + stripeDefaultQuantity := viper.GetInt64("stripeDefaultQuantity") + stripeWebhookSecret := viper.GetString("stripeWebhookSecret") // parse auth types and amounts authNames := strings.Split(auth, ",") @@ -175,13 +207,21 @@ func main() { if err != nil { log.Fatal(err) } - // create the faucet instance f := faucet{ signer: &signer, authTypes: authTypes, waitPeriod: waitPeriod, storage: storage, + stripe: &stripehandler.StripeProvider{ + Key: stripeKey, + PriceId: stripePriceId, + MinQuantity: stripeMinQuantity, + MaxQuantity: stripeMaxQuantity, + DefaultAmount: stripeDefaultQuantity, + WebhookSecret: stripeWebhookSecret, + }, + domain: fmt.Sprintf("http://%s:%d%s", listenHost, listenPort, baseRoute), } // init API diff --git a/storage/storage.go b/storage/storage.go index c21c412..cdea250 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -73,7 +73,18 @@ func (st *Storage) AddFundedUserWithWaitTime(userID []byte, authType string) err return tx.Commit() } -// CheckFundedUserWithWaitTime checks if the given text is funded and returns true if it is, within +// addFundedUserID adds the given userID to the funded list, with the current time +// as the wait period end time. +func (st *Storage) AddSessionLastFaucetPackage(stipeSessionId string, faucetPackage []byte) error { + tx := st.kv.WriteTx() + defer tx.Discard() + if err := tx.Set([]byte(stipeSessionId), []byte(faucetPackage)); err != nil { + log.Error(err) + } + return tx.Commit() +} + +// checkIsFundedUserID checks if the given text is funded and returns true if it is, within // the wait period time window. Otherwise, it returns false. func (st *Storage) CheckFundedUserWithWaitTime(userID []byte, authType string) (bool, time.Time) { key := append(userID, []byte(authType)...) @@ -84,3 +95,43 @@ func (st *Storage) CheckFundedUserWithWaitTime(userID []byte, authType string) ( wp := binary.LittleEndian.Uint64(wpBytes) return wp >= uint64(time.Now().Unix()), time.Unix(int64(wp), 0) } + +// checkIsFundedUserID checks if the given text is funded and returns true if it is, within +// the wait period time window. Otherwise, it returns false. +func (st *Storage) GetSessionLastFaucetPackage(stipeSessionId string) ([]byte, error) { + wpBytes, err := st.kv.Get([]byte(stipeSessionId)) + if err != nil { + return []byte{}, err + } + + return wpBytes, nil +} + +func (st *Storage) AddPendingStripeSession(sessionID string) error { + tx := st.kv.WriteTx() + defer tx.Discard() + if err := tx.Set([]byte(sessionID), []byte("true")); err != nil { + log.Error(err) + } + return tx.Commit() +} + +func (st *Storage) GetPendingStripeSession(sessionID string) (bool, error) { + data, err := st.kv.Get([]byte(sessionID)) + if err != nil { + return false, err + } + if string(data) == "true" { + return true, nil + } + return false, nil +} + +func (st *Storage) RemovePendingStripeSession(sessionID string) error { + tx := st.kv.WriteTx() + defer tx.Discard() + if err := tx.Delete([]byte(sessionID)); err != nil { + return err + } + return tx.Commit() +} diff --git a/stripehandler/handler.go b/stripehandler/handler.go new file mode 100644 index 0000000..0336526 --- /dev/null +++ b/stripehandler/handler.go @@ -0,0 +1,124 @@ +package stripehandler + +import ( + "encoding/json" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/checkout/session" + "github.com/stripe/stripe-go/v78/webhook" + "go.vocdoni.io/dvote/httprouter/apirest" +) + +// StripeProvider represents a provider for handling Stripe payments. +type StripeProvider struct { + Key string // The API key for the Stripe account. + PriceId string // The ID of the price associated with the product. + MinQuantity int64 // The minimum quantity allowed for the product. + MaxQuantity int64 // The maximum quantity allowed for the product. + DefaultAmount int64 // The default amount for the product. + WebhookSecret string // The secret used to verify Stripe webhook events. +} + +// ReturnStatus represents the response status and data returned by the handler. +type ReturnStatus struct { + Status string `json:"status"` + CustomerEmail string `json:"customer_email"` + FaucetPackage []byte `json:"faucet_package"` + Recipient string `json:"recipient"` + Quantity int64 `json:"quantity"` +} + +// NewStripePovider creates a new instance of the StripeProvider struct with the provided parameters. +// It sets the Stripe API key, price ID, webhook secret, minimum quantity, maximum quantity, and default amount. +// Returns a pointer to the created StripeProvider. +func NewStripePovider(key, priceId, webhookSecret string, minQuantity, maxQuantity, defaultAmount int64) *StripeProvider { + stripe.Key = key + return &StripeProvider{ + PriceId: priceId, + MinQuantity: minQuantity, + MaxQuantity: maxQuantity, + DefaultAmount: defaultAmount, + WebhookSecret: webhookSecret, + } +} + +// CreateCheckoutSession creates a new Stripe checkout session. +// It takes the defaultAmount, to, and referral as parameters and returns a pointer to a stripe.CheckoutSession and an error. +// The defaultAmount parameter specifies the default quantity for the checkout session. +// The to parameter is the client reference ID for the checkout session. +// The referral parameter is the referral URL for the checkout session. +// The function constructs a stripe.CheckoutSessionParams object with the provided parameters and creates a new session using the session.New function. +// If the session creation is successful, it returns the session pointer, otherwise it returns an error. +func (s *StripeProvider) CreateCheckoutSession(defaultAmount int64, to string, referral string) (*stripe.CheckoutSession, error) { + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(to), + UIMode: stripe.String("embedded"), + ReturnURL: stripe.String("http://" + referral + ":5173/stripe/return/{CHECKOUT_SESSION_ID}"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(s.PriceId), + AdjustableQuantity: &stripe.CheckoutSessionLineItemAdjustableQuantityParams{ + Enabled: stripe.Bool(true), + Minimum: stripe.Int64(int64(s.MinQuantity)), + Maximum: stripe.Int64(int64(s.MaxQuantity)), + }, + Quantity: stripe.Int64(int64(defaultAmount)), + }, + }, + Metadata: map[string]string{ + "to": to, + "referral": referral, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + ses, err := session.New(params) + if err != nil { + return nil, err + } + return ses, nil +} + +// RetrieveCheckoutSession retrieves a checkout session from Stripe by session ID. +// It returns a ReturnStatus object and an error if any. +// The ReturnStatus object contains information about the session status, customer email, +// faucet package, recipient, and quantity. +func (s *StripeProvider) RetrieveCheckoutSession(sessionID string) (*ReturnStatus, error) { + params := &stripe.CheckoutSessionParams{} + params.AddExpand("line_items") + sess, err := session.Get(sessionID, params) + if err != nil { + return nil, err + } + lineItems := sess.LineItems + data := &ReturnStatus{ + Status: string(sess.Status), + CustomerEmail: sess.CustomerDetails.Email, + FaucetPackage: nil, + Recipient: sess.Metadata["to"], + Quantity: lineItems.Data[0].Quantity, + } + return data, nil +} + +// HandleWebhook handles the incoming webhook event from Stripe. +// It takes the API data and signature as input parameters and returns the session ID and an error (if any). +// The request body and Stripe-Signature header are passed to ConstructEvent, along with the webhook signing key. +// If the event type is "checkout.session.completed", it unmarshals the event data into a CheckoutSession struct +// and returns the session ID. Otherwise, it returns an empty string. +func (s *StripeProvider) HandleWebhook(apiData *apirest.APIdata, sig string) (string, error) { + // Pass the request body and Stripe-Signature header to ConstructEvent, along with the webhook signing key + event, err := webhook.ConstructEvent(apiData.Data, `sig`, s.WebhookSecret) + if err != nil { + return "", err + } + // Handle the checkout.session.completed event + if event.Type == "checkout.session.completed" { + var sess stripe.CheckoutSession + err := json.Unmarshal(event.Data.Raw, &sess) + if err != nil { + return "", err + } + return sess.ID, nil + } + return "", nil +}