From 5332fa94bb9d300dc6727cbd9b54d05ee218ec22 Mon Sep 17 00:00:00 2001 From: emmdim Date: Fri, 31 May 2024 12:50:42 +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 | 38 +++++++++++ go.mod | 1 + go.sum | 3 + handlers.go | 151 +++++++++++++++++++++++++++++++++++++++++++ handlers_response.go | 2 + main.go | 41 +++++++++++- storage.go | 22 +++++++ 8 files changed, 269 insertions(+), 1 deletion(-) 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 74e64c4..438c761 100644 --- a/faucet.go +++ b/faucet.go @@ -17,6 +17,17 @@ type faucet struct { authTypes map[string]uint64 waitPeriod time.Duration storage *storage + stripe *stripeInfo + domain string +} + +type stripeInfo struct { + key string + priceId string + minQuantity int + maxQuantity int + defaultAmount int + webhookSecret string } // prepareFaucetPackage prepares a faucet package, including the signature, for the given address. @@ -45,3 +56,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) prepareFaucetPackageAmmount(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 4192df1..bd22749 100644 --- a/handlers.go +++ b/handlers.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "net/http" "github.com/ethereum/go-ethereum/common" "github.com/vocdoni/vocfaucet/aragondaohandler" @@ -11,6 +12,10 @@ import ( "go.vocdoni.io/dvote/httprouter/apirest" "go.vocdoni.io/dvote/log" "go.vocdoni.io/dvote/types" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/checkout/session" + "github.com/stripe/stripe-go/v78/webhook" ) // Register the handlers URLs @@ -24,6 +29,33 @@ 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( + "/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 +99,125 @@ func (f *faucet) registerHandlers(api *apirest.API) { } } +// createCheckoutSession creates a new Stripe Checkout session +func (f *faucet) createCheckoutSession(_ *apirest.APIdata, ctx *httprouter.HTTPContext) error { + stripe.Key = f.stripe.key + to := ctx.URLParam("to") + referral := ctx.URLParam("referral") + params := &stripe.CheckoutSessionParams{ + // A unique string to reference the Checkout Session. + ClientReferenceID: stripe.String(to), + UIMode: stripe.String("embedded"), + // TODO remove port from domain + ReturnURL: stripe.String("http://" + referral + ":5173/return/{CHECKOUT_SESSION_ID}"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(f.stripe.priceId), + AdjustableQuantity: &stripe.CheckoutSessionLineItemAdjustableQuantityParams{ + Enabled: stripe.Bool(true), + Minimum: stripe.Int64(int64(f.stripe.minQuantity)), + Maximum: stripe.Int64(int64(f.stripe.maxQuantity)), + }, + Quantity: stripe.Int64(int64(f.stripe.defaultAmount)), + }, + }, + Metadata: map[string]string{ + "to": to, + "referral": referral, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + s, err := session.New(params) + + 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 { + stripe.Key = f.stripe.key + s, err := session.Get((ctx.URLParam("session_id")), nil) + if err != nil { + return err + } + + faucetPackage, err := f.storage.getSessionLastFaucetPackage(s.ID) + if err != nil { + log.Warnf("error getting last faucet package for session %s: %v", s.ID, err) + } + + data := &struct { + Status string `json:"status"` + CustomerEmail string `json:"customer_email"` + FaucetPackage []byte `json:"faucet_package"` + Recipient string `json:"recipient"` + }{ + Status: string(s.Status), + CustomerEmail: string(s.CustomerDetails.Email), + FaucetPackage: faucetPackage, + Recipient: s.Metadata["to"], + } + return ctx.Send(new(HandlerResponse).Set(data).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 + event, err := webhook.ConstructEvent(apiData.Data, sig, f.stripe.webhookSecret) + + if err != nil { + errReason := fmt.Sprintf("Error verifying webhook signature: %v\n", err) + return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), http.StatusBadRequest) + } + + stripe.Key = f.stripe.key + // 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 { + errReason := fmt.Sprintf("Error parsing webhook JSON: %v\n", err) + return ctx.Send(new(HandlerResponse).SetError(errReason).MustMarshall(), http.StatusBadRequest) + } + + params := &stripe.CheckoutSessionParams{} + params.AddExpand("line_items") + // Retrieve the session. If you require line items in the response, you may include them by expanding line_items. + sessionWithLineItems, _ := session.Get(sess.ID, params) + lineItems := sessionWithLineItems.LineItems + return f.processPaymentTransfer(ctx, sess.ID, lineItems.Data[0].Quantity, sessionWithLineItems.Metadata["to"]) + } + + return ctx.Send([]byte("success"), http.StatusOK) +} + +func (f *faucet) processPaymentTransfer(ctx *httprouter.HTTPContext, stipeSessionId string, amount int64, to string) error { + if amount == 0 { + return ctx.Send(new(HandlerResponse).SetError(ReasonErrUnsupportedAuthType).MustMarshall(), CodeErrUnsupportedAuthType) + } + addr, err := stringToAddress(to) + if err != nil { + return err + } + data, err := f.prepareFaucetPackageAmmount(addr, uint64(amount)) + if err != nil { + return err + } + if err := f.storage.addSessionLastFaucetPackage(stipeSessionId, data.FaucetPackage); err != nil { + return err + } + return ctx.Send(new(HandlerResponse).Set(data).MustMarshall(), apirest.HTTPstatusOK) +} + // 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 b8b2231..3b6dcc9 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,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.Int("stripeMinQuantity", 100, "stripe min number of tokens") + flag.Int("stripeMaxQuantity", 100000, "stripe max number of tokens") + flag.Int("stripeDefaultQuantity", 100, "stripe default number of tokens") + flag.String("stripeWebhookSecret", "", "stripe webhook secret key") flag.Parse() // Setting up viper @@ -86,6 +92,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")) @@ -121,8 +145,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.GetInt("stripeMinQuantity") + stripeMaxQuantity := viper.GetInt("stripeMaxQuantity") + stripeDefaultQuantity := viper.GetInt("stripeDefaultQuantity") + stripeWebhookSecret := viper.GetString("stripeWebhookSecret") // parse auth types and amounts authNames := strings.Split(auth, ",") @@ -174,13 +205,21 @@ func main() { if err != nil { log.Fatal(err) } - // create the faucet instance f := faucet{ signer: &signer, authTypes: authTypes, waitPeriod: waitPeriod, storage: storage, + stripe: &stripeInfo{ + 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.go b/storage.go index 1c91685..4d62c59 100644 --- a/storage.go +++ b/storage.go @@ -50,6 +50,17 @@ func (st *storage) addFundedUserID(userID []byte, authType string) error { return tx.Commit() } +// 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) checkIsFundedUserID(userID []byte, authType string) (bool, time.Time) { @@ -61,3 +72,14 @@ func (st *storage) checkIsFundedUserID(userID []byte, authType string) (bool, ti 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 +}