Skip to content

Commit 360c04d

Browse files
committed
feat: prepare payment middleware
1 parent 327ed65 commit 360c04d

File tree

15 files changed

+645
-454
lines changed

15 files changed

+645
-454
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package payctx
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"slices"
7+
)
8+
9+
// Payment holds information about a processed payment stored in the request context
10+
type Payment struct { //nolint: revive // Ignore that struct starts with package name
11+
// SatoshisPaid is the amount paid in satoshis
12+
SatoshisPaid int
13+
// Accepted indicates whether the payment was accepted
14+
Accepted bool
15+
// Tx is the payment transaction data
16+
Tx []byte
17+
}
18+
19+
// contextKey is a private type for context keys
20+
type contextKey string
21+
22+
// PaymentKey is the context key for payment info
23+
const paymentKey contextKey = "payment"
24+
25+
func WithPayment(ctx context.Context, info *Payment) context.Context {
26+
payment := Payment{
27+
SatoshisPaid: info.SatoshisPaid,
28+
Accepted: info.Accepted,
29+
Tx: slices.Clone(info.Tx),
30+
}
31+
32+
return context.WithValue(ctx, paymentKey, payment)
33+
}
34+
35+
func WithoutPayment(ctx context.Context) context.Context {
36+
return context.WithValue(ctx, paymentKey, Payment{SatoshisPaid: 0})
37+
}
38+
39+
// ShouldGetPayment retrieves payment info from context
40+
func ShouldGetPayment(ctx context.Context) (*Payment, error) {
41+
contextValue := ctx.Value(paymentKey)
42+
if contextValue == nil {
43+
return nil, fmt.Errorf("%s not found in context", paymentKey)
44+
}
45+
46+
payment, ok := contextValue.(Payment)
47+
if !ok {
48+
return nil, fmt.Errorf("%s contains unexpected type %T", paymentKey, contextValue)
49+
}
50+
51+
return &payment, nil
52+
}
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ const (
55
// PaymentVersion is the version of the DPP protocol implementation
66
PaymentVersion = "1.0"
77

8-
// NetworkBSV is the identifier for the Bitcoin SV network
9-
NetworkBSV = "bitcoin-sv"
8+
PaymentOriginator = "paymentMiddleware"
109
)
1110

1211
// HTTP Header constants
@@ -44,9 +43,9 @@ const (
4443
// ErrCodeInvalidPrefix indicates an invalid derivation prefix
4544
ErrCodeInvalidPrefix = "ERR_INVALID_DERIVATION_PREFIX"
4645

46+
// ErrCodeInvalidSuffix indicates an invalid derivation suffix
47+
ErrCodeInvalidSuffix = "ERR_INVALID_DERIVATION_SUFFIX"
48+
4749
// ErrCodePaymentFailed indicates a payment processing failure
4850
ErrCodePaymentFailed = "ERR_PAYMENT_FAILED"
49-
50-
// ErrCodePaymentNotFound indicates a payment identifier was not found
51-
ErrCodePaymentNotFound = "ERR_PAYMENT_NOT_FOUND"
5251
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package payment
2+
3+
import (
4+
"net/http"
5+
)
6+
7+
const (
8+
defaultPrice = 100
9+
)
10+
11+
// DefaultPriceFunc returns a basic pricing function that applies a flat rate
12+
func DefaultPriceFunc(_ *http.Request) (int, error) {
13+
return defaultPrice, nil
14+
}

pkg/internal/payment/errors.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package payment
2+
3+
import "net/http"
4+
5+
type ErrorStatus string
6+
7+
func (s ErrorStatus) MarshalJSON() ([]byte, error) {
8+
return []byte("error"), nil
9+
}
10+
11+
type ErrorResponse struct {
12+
StatusCode int `json:"-"`
13+
Status ErrorStatus `json:"status"`
14+
Code string `json:"code"`
15+
Description string `json:"description"`
16+
}
17+
18+
func (e ErrorResponse) GetStatusCode() int {
19+
return e.StatusCode
20+
}
21+
22+
type SatoshisRequired struct {
23+
ErrorResponse
24+
SatoshisRequired int
25+
}
26+
27+
// WithSatoshisRequired returns given response with satoshis required set.
28+
//
29+
// This needs to be value receiver, so we don't need to copy all the fields.
30+
func (e SatoshisRequired) WithSatoshisRequired(price int) SatoshisRequired {
31+
e.SatoshisRequired = price
32+
return e
33+
}
34+
35+
var (
36+
ErrServerMisconfigured = ErrorResponse{
37+
StatusCode: http.StatusInternalServerError,
38+
Code: ErrCodeServerMisconfigured,
39+
Description: "The payment middleware must be executed after the Auth middleware.",
40+
}
41+
ErrPaymentInternal = ErrorResponse{
42+
StatusCode: http.StatusInternalServerError,
43+
Code: ErrCodePaymentInternal,
44+
Description: "An internal error occurred while processing the payment.",
45+
}
46+
ErrMalformedPayment = ErrorResponse{
47+
StatusCode: http.StatusBadRequest,
48+
Code: ErrCodeMalformedPayment,
49+
Description: "The X-BSV-Payment header is not valid JSON.",
50+
}
51+
52+
ErrPaymentRequired = SatoshisRequired{
53+
ErrorResponse: ErrorResponse{
54+
StatusCode: http.StatusPaymentRequired,
55+
Code: ErrCodePaymentRequired,
56+
Description: "A BSV payment is required to complete this request. Provide the X-BSV-Payment header.",
57+
},
58+
}
59+
ErrInvalidDerivationPrefix = ErrorResponse{
60+
StatusCode: http.StatusBadRequest,
61+
Code: ErrCodeInvalidPrefix,
62+
Description: "The X-BSV-Payment header Derivation Prefix is not valid. Must be the same as the one provided one.",
63+
}
64+
ErrInvalidDerivationSuffix = ErrorResponse{
65+
StatusCode: http.StatusBadRequest,
66+
Code: ErrCodeInvalidSuffix,
67+
Description: "The X-BSV-Payment header Derivation Suffix is not valid. Must be base64.",
68+
}
69+
ErrPaymentFailed = ErrorResponse{
70+
StatusCode: http.StatusBadRequest,
71+
Code: ErrCodePaymentFailed,
72+
Description: "Payment failed.",
73+
}
74+
)
75+
76+
type ProcessingError struct {
77+
ErrorResponse
78+
Cause error `json:"-"`
79+
}
80+
81+
func NewProcessingError(response ErrorResponse, cause error) *ProcessingError {
82+
return &ProcessingError{
83+
ErrorResponse: response,
84+
Cause: cause,
85+
}
86+
}

pkg/internal/payment/middleware.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package payment
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"log/slog"
9+
"net/http"
10+
"strconv"
11+
12+
"github.com/bsv-blockchain/go-bsv-middleware/pkg/internal/authctx"
13+
"github.com/bsv-blockchain/go-bsv-middleware/pkg/internal/payctx"
14+
sdkUtils "github.com/bsv-blockchain/go-sdk/auth/utils"
15+
ec "github.com/bsv-blockchain/go-sdk/primitives/ec"
16+
"github.com/bsv-blockchain/go-sdk/wallet"
17+
"github.com/go-softwarelab/common/pkg/slogx"
18+
"github.com/go-softwarelab/common/pkg/to"
19+
)
20+
21+
type Config struct {
22+
Logger *slog.Logger
23+
24+
// CalculateRequestPrice determines the cost in satoshis for a request
25+
CalculateRequestPrice func(r *http.Request) (int, error)
26+
}
27+
28+
// Middleware is the payment middleware handler that implements Direct Payment Protocol (DPP) for HTTP-based micropayments
29+
type Middleware struct {
30+
log *slog.Logger
31+
wallet wallet.Interface
32+
calculateRequestPrice func(r *http.Request) (int, error)
33+
nextHandler http.Handler
34+
}
35+
36+
func NewMiddleware(next http.Handler, wallet wallet.Interface, opts ...func(*Config)) *Middleware {
37+
cfg := to.OptionsWithDefault(Config{
38+
CalculateRequestPrice: DefaultPriceFunc,
39+
Logger: slog.Default(),
40+
}, opts...)
41+
42+
logger := slogx.Child(cfg.Logger, "PaymentMiddleware")
43+
44+
return &Middleware{
45+
wallet: wallet,
46+
nextHandler: next,
47+
log: logger,
48+
calculateRequestPrice: cfg.CalculateRequestPrice,
49+
}
50+
}
51+
52+
// Handler returns a middleware handler function that processes payments
53+
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54+
ctx := r.Context()
55+
56+
identityKey, err := authctx.ShouldGetIdentity(r.Context())
57+
if err != nil {
58+
m.log.ErrorContext(ctx, "Failed to get identity from request context", slogx.Error(err))
59+
m.respondWith(w, ErrServerMisconfigured)
60+
return
61+
}
62+
63+
log := m.log.With(slog.String("identityKey", identityKey.ToDERHex()))
64+
65+
price, err := m.calculateRequestPrice(r)
66+
if err != nil {
67+
log.ErrorContext(ctx, "Failed to calculate request price", slogx.Error(err))
68+
m.respondWith(w, ErrPaymentInternal)
69+
return
70+
}
71+
72+
if price == 0 {
73+
log.DebugContext(ctx, "Request without payment requested, proceeding to next handler", slog.Int("price", price))
74+
m.proceedWithoutPayment(w, r)
75+
return
76+
}
77+
78+
paymentData, err := m.extractPaymentData(r)
79+
if err != nil {
80+
log.ErrorContext(ctx, "Failed to extract payment data", slogx.Error(err))
81+
m.respondWith(w, ErrMalformedPayment)
82+
return
83+
}
84+
85+
if paymentData == nil {
86+
log.DebugContext(ctx, "Requesting payment", slog.Int("price", price))
87+
err = m.requestPayment(w, r, price)
88+
if err != nil {
89+
log.ErrorContext(ctx, "Failed to prepare payment request", slogx.Error(err))
90+
m.respondWith(w, ErrPaymentInternal)
91+
}
92+
return
93+
}
94+
95+
log.DebugContext(ctx, "Processing payment", slog.Int("price", price))
96+
paymentInfo, processErr := m.processPayment(ctx, paymentData, identityKey, price)
97+
if processErr != nil {
98+
log.ErrorContext(ctx, "Failed to process payment", slogx.Error(processErr.Cause))
99+
m.respondWith(w, processErr)
100+
return
101+
}
102+
103+
log.DebugContext(ctx, "Request successfully paid, proceeding to next handler", slog.Int("price", price))
104+
m.proceedWithSuccessfulPayment(w, r, paymentInfo)
105+
}
106+
107+
func (m *Middleware) respondWith(w http.ResponseWriter, resp Response) {
108+
w.Header().Set("Content-Type", "application/json")
109+
w.WriteHeader(resp.GetStatusCode())
110+
err := json.NewEncoder(w).Encode(resp)
111+
if err != nil {
112+
m.log.Error("Error writing response body", slog.Any("response", resp), slogx.Error(err))
113+
return
114+
}
115+
}
116+
117+
func (m *Middleware) proceedWithoutPayment(w http.ResponseWriter, r *http.Request) {
118+
ctx := payctx.WithoutPayment(r.Context())
119+
m.nextHandler.ServeHTTP(w, r.WithContext(ctx))
120+
}
121+
122+
func (m *Middleware) extractPaymentData(r *http.Request) (*Payment, error) {
123+
paymentHeader := r.Header.Get(HeaderPayment)
124+
if paymentHeader == "" {
125+
return nil, nil
126+
}
127+
128+
var payment Payment
129+
if err := json.Unmarshal([]byte(paymentHeader), &payment); err != nil {
130+
return nil, fmt.Errorf("invalid payment data format: %w", err)
131+
}
132+
133+
return &payment, nil
134+
}
135+
136+
func (m *Middleware) requestPayment(w http.ResponseWriter, r *http.Request, price int) error {
137+
derivationPrefix, err := sdkUtils.CreateNonce(r.Context(), m.wallet, wallet.Counterparty{Type: wallet.CounterpartyTypeSelf})
138+
if err != nil {
139+
return fmt.Errorf("failed to prepare derivation prefix as nonce: %w", err)
140+
}
141+
142+
w.Header().Set(HeaderVersion, PaymentVersion)
143+
w.Header().Set(HeaderSatoshisRequired, strconv.Itoa(price))
144+
w.Header().Set(HeaderDerivationPrefix, derivationPrefix)
145+
146+
m.respondWith(w, ErrPaymentRequired.WithSatoshisRequired(price))
147+
148+
return nil
149+
}
150+
151+
func (m *Middleware) proceedWithSuccessfulPayment(w http.ResponseWriter, r *http.Request, paymentInfo *payctx.Payment) {
152+
ctx := payctx.WithPayment(r.Context(), paymentInfo)
153+
w.Header().Set(HeaderSatoshisPaid, strconv.Itoa(paymentInfo.SatoshisPaid))
154+
m.nextHandler.ServeHTTP(w, r.WithContext(ctx))
155+
}
156+
157+
func (m *Middleware) processPayment(
158+
ctx context.Context,
159+
paymentData *Payment,
160+
identityKey *ec.PublicKey,
161+
price int,
162+
) (*payctx.Payment, *ProcessingError) {
163+
derivationPrefix, err := base64.StdEncoding.DecodeString(paymentData.DerivationPrefix)
164+
if err != nil {
165+
return nil, NewProcessingError(ErrInvalidDerivationPrefix, fmt.Errorf("invalid derivation prefix: must be base64: %w", err))
166+
}
167+
168+
valid, err := sdkUtils.VerifyNonce(ctx, paymentData.DerivationPrefix, m.wallet, wallet.Counterparty{Type: wallet.CounterpartyTypeSelf})
169+
if err != nil {
170+
return nil, NewProcessingError(ErrInvalidDerivationPrefix, fmt.Errorf("error verifying derivation prefix as nonce: %w", err))
171+
}
172+
if !valid {
173+
return nil, NewProcessingError(ErrInvalidDerivationPrefix, fmt.Errorf("derivation prefix is invalid nonce"))
174+
}
175+
176+
derivationSuffix, err := base64.StdEncoding.DecodeString(paymentData.DerivationSuffix)
177+
if err != nil {
178+
return nil, NewProcessingError(ErrInvalidDerivationSuffix, fmt.Errorf("invalid derivation suffix: must be base64: %w", err))
179+
}
180+
181+
result, err := m.wallet.InternalizeAction(ctx, wallet.InternalizeActionArgs{
182+
Tx: paymentData.Transaction,
183+
Outputs: []wallet.InternalizeOutput{
184+
{
185+
OutputIndex: 0,
186+
Protocol: wallet.InternalizeProtocolWalletPayment,
187+
PaymentRemittance: &wallet.Payment{
188+
DerivationPrefix: derivationPrefix,
189+
DerivationSuffix: derivationSuffix,
190+
SenderIdentityKey: identityKey,
191+
},
192+
},
193+
},
194+
Description: "Payment for request",
195+
},
196+
PaymentOriginator,
197+
)
198+
if err != nil {
199+
return nil, NewProcessingError(ErrPaymentFailed, fmt.Errorf("payment processing failed: %w", err))
200+
}
201+
202+
return &payctx.Payment{
203+
SatoshisPaid: price,
204+
Accepted: result.Accepted,
205+
Tx: paymentData.Transaction,
206+
}, nil
207+
}

0 commit comments

Comments
 (0)