main
go 355 lines 11 KB
Raw
1 package x402
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "strings"
11
12 "github.com/cockroachdb/apd/v3"
13 facilitatorcore "github.com/gosuda/x402-facilitator/facilitator"
14 suischeme "github.com/gosuda/x402-facilitator/scheme/sui"
15 facilitatortypes "github.com/gosuda/x402-facilitator/types"
16 "github.com/rs/zerolog/log"
17
18 "github.com/gosuda/portal-tunnel/v2/types"
19 "github.com/gosuda/portal-tunnel/v2/utils"
20 )
21
22 const usdcDecimals = 6
23
24 // Payment owns one Sui USDC x402 payment contract and its facilitator runtime.
25 type Payment struct {
26 payment types.X402Payment
27 facilitator facilitatorcore.Facilitator
28 requirements facilitatortypes.PaymentRequirements
29 }
30
31 func NewUSDCPayment(payment types.X402Payment) (*Payment, error) {
32 network := strings.TrimSpace(payment.Network)
33 if network == "" {
34 network = Network(payment.Testnet)
35 }
36 network = strings.ToLower(network)
37 asset, err := usdcAsset(network)
38 if err != nil {
39 return nil, err
40 }
41 payTo := suischeme.NormalizeAddress(payment.PayTo)
42 if payTo == "" {
43 return nil, errors.New("x402 USDC payment requires a Sui pay-to address")
44 }
45 amount, err := USDCAmountToAtomic(payment.Amount)
46 if err != nil {
47 return nil, err
48 }
49 maxTimeoutSeconds := payment.MaxTimeoutSeconds
50 if maxTimeoutSeconds <= 0 {
51 maxTimeoutSeconds = defaultMaxTimeoutSeconds
52 }
53 requirements := facilitatortypes.PaymentRequirements{
54 Scheme: string(facilitatortypes.Exact),
55 Network: network,
56 Asset: asset,
57 Amount: amount,
58 PayTo: payTo,
59 MaxTimeoutSeconds: maxTimeoutSeconds,
60 Extra: map[string]interface{}{
61 "asset": "USDC",
62 "assetTransferMethod": "sui-gasless-stablecoin-address-balance",
63 },
64 }
65 endpoints := append([]string(nil), payment.Endpoints...)
66 facilitator, err := newUSDCFacilitator(requirements.Network, requirements.Asset, endpoints...)
67 if err != nil {
68 return nil, err
69 }
70 networkName := NetworkDisplayName(requirements.Network)
71 if networkName == "" {
72 networkName = requirements.Network
73 }
74 payment.Testnet = strings.EqualFold(requirements.Network, TestnetNetwork)
75 payment.Network = requirements.Network
76 payment.NetworkName = networkName
77 payment.Asset = requirements.Asset
78 payment.PayTo = requirements.PayTo
79 payment.Amount = requirements.Amount
80 payment.MaxTimeoutSeconds = requirements.MaxTimeoutSeconds
81 payment.Endpoints = endpoints
82 payment.ResourcePath = strings.TrimSpace(payment.ResourcePath)
83 payment.ResourceDescription = strings.TrimSpace(payment.ResourceDescription)
84 payment.ResourceMimeType = strings.TrimSpace(payment.ResourceMimeType)
85
86 return &Payment{
87 payment: payment,
88 facilitator: facilitator,
89 requirements: requirements,
90 }, nil
91 }
92
93 // USDCAmountToAtomic converts a human USDC amount, such as "0.01", to atomic
94 // units for the x402 facilitator. USDC has 6 decimals.
95 func USDCAmountToAtomic(amount string) (string, error) {
96 amount = strings.TrimSpace(amount)
97 if amount == "" {
98 return "", errors.New("x402 USDC payment amount is required")
99 }
100 d, _, err := new(apd.Decimal).SetString(amount)
101 if err != nil {
102 return "", fmt.Errorf("x402 USDC payment amount must be a decimal USDC amount: %s", amount)
103 }
104 d.Exponent += int32(usdcDecimals)
105 d.Reduce(d)
106 if d.Form != apd.Finite || d.Sign() <= 0 {
107 return "", fmt.Errorf("x402 USDC payment amount must be positive: %s", amount)
108 }
109 if d.Exponent < 0 {
110 return "", fmt.Errorf("x402 USDC payment amount supports up to %d decimals: %s", usdcDecimals, amount)
111 }
112 return fmt.Sprintf("%f", d), nil
113 }
114
115 // FormatUSDCAtomicAmount renders an atomic USDC amount as a human amount.
116 func FormatUSDCAtomicAmount(amount string) string {
117 amount = strings.TrimSpace(amount)
118 if amount == "" {
119 return ""
120 }
121 d, _, err := new(apd.Decimal).SetString(amount)
122 if err != nil {
123 return amount + " atomic USDC"
124 }
125 d.Exponent -= int32(usdcDecimals)
126 d.Reduce(d)
127 if d.Form != apd.Finite || d.Sign() < 0 {
128 return amount + " atomic USDC"
129 }
130 return fmt.Sprintf("%f USDC", d)
131 }
132
133 func (p *Payment) paymentPayloadFromRequest(w http.ResponseWriter, r *http.Request) (*facilitatortypes.PaymentPayload, bool) {
134 if p == nil {
135 http.Error(w, "payment is not configured", http.StatusInternalServerError)
136 return nil, false
137 }
138
139 rawPayment := ""
140 for _, name := range []string{types.HeaderXPayment, types.HeaderPaymentSignature} {
141 if value := strings.TrimSpace(r.Header.Get(name)); value != "" {
142 rawPayment = value
143 break
144 }
145 }
146 if rawPayment == "" {
147 p.writePaymentRequired(w, r, "payment required")
148 return nil, false
149 }
150
151 var payload *facilitatortypes.PaymentPayload
152 var decoded facilitatortypes.PaymentPayload
153 if err := json.Unmarshal([]byte(rawPayment), &decoded); err == nil {
154 payload = &decoded
155 }
156 if payload == nil {
157 for _, encoding := range []*base64.Encoding{
158 base64.StdEncoding,
159 base64.RawStdEncoding,
160 base64.URLEncoding,
161 base64.RawURLEncoding,
162 } {
163 raw, err := encoding.DecodeString(rawPayment)
164 if err != nil {
165 continue
166 }
167 var decoded facilitatortypes.PaymentPayload
168 if err := json.Unmarshal(raw, &decoded); err == nil {
169 payload = &decoded
170 break
171 }
172 }
173 }
174 if payload == nil {
175 p.writePaymentRequired(w, r, "invalid payment payload")
176 return nil, false
177 }
178 return payload, true
179 }
180
181 func (p *Payment) Settle(ctx context.Context, w http.ResponseWriter, r *http.Request) (*facilitatortypes.PaymentSettleResponse, bool) {
182 if p == nil {
183 http.Error(w, "payment is not configured", http.StatusInternalServerError)
184 return nil, false
185 }
186 if p.facilitator == nil {
187 http.Error(w, "x402 facilitator is not configured", http.StatusInternalServerError)
188 return nil, false
189 }
190 payment, ok := p.paymentPayloadFromRequest(w, r)
191 if !ok {
192 return nil, false
193 }
194 settled, err := p.facilitator.Settle(ctx, payment, &p.requirements)
195 if err != nil {
196 log.Warn().
197 Err(err).
198 Str("network", p.requirements.Network).
199 Str("asset", p.requirements.Asset).
200 Msg("settle x402 payment")
201 p.writePaymentRequired(w, r, "payment settlement failed")
202 return nil, false
203 }
204 if settled == nil || !settled.Success {
205 event := log.Warn().
206 Str("network", p.requirements.Network).
207 Str("asset", p.requirements.Asset)
208 if settled != nil {
209 errorMessage := strings.TrimSpace(settled.ErrorMessage)
210 if errorMessage == "" {
211 errorMessage = "<empty>"
212 }
213 event = event.
214 Str("reason", strings.TrimSpace(settled.ErrorReason)).
215 Str("error_message", errorMessage).
216 Str("payer", strings.TrimSpace(settled.Payer)).
217 Str("transaction", strings.TrimSpace(settled.Transaction))
218 }
219 event.Msg("x402 payment settlement rejected")
220 p.writePaymentRequired(w, r, "payment settlement failed")
221 return nil, false
222 }
223 return settled, true
224 }
225
226 func (p *Payment) writePaymentRequired(w http.ResponseWriter, r *http.Request, reason string) {
227 if p == nil {
228 http.Error(w, reason, http.StatusPaymentRequired)
229 return
230 }
231 resourceURL := ""
232 if r != nil && r.URL != nil {
233 resourceURL = utils.PublicURLForPath(r, r.URL.RequestURI())
234 }
235 body := struct {
236 X402Version int `json:"x402Version"`
237 Error string `json:"error,omitempty"`
238 Resource *facilitatortypes.ResourceInfo `json:"resource,omitempty"`
239 Accepts []facilitatortypes.PaymentRequirements `json:"accepts"`
240 }{
241 X402Version: int(facilitatortypes.X402VersionV2),
242 Error: strings.TrimSpace(reason),
243 Resource: &facilitatortypes.ResourceInfo{URL: resourceURL},
244 Accepts: []facilitatortypes.PaymentRequirements{p.requirements},
245 }
246 raw, err := json.Marshal(body)
247 if err != nil {
248 http.Error(w, "encode x402 payment requirements", http.StatusInternalServerError)
249 return
250 }
251 encoded := base64.StdEncoding.EncodeToString(raw)
252 w.Header().Set("Content-Type", "application/json")
253 w.Header().Set(types.HeaderPaymentRequired, encoded)
254 w.Header().Set(types.HeaderXPaymentRequired, encoded)
255 w.WriteHeader(http.StatusPaymentRequired)
256 _, _ = w.Write(raw)
257 }
258
259 func (p *Payment) WritePrepare(w http.ResponseWriter, r *http.Request, sender, resourcePath string) {
260 if p == nil {
261 http.Error(w, "payment is not configured", http.StatusInternalServerError)
262 return
263 }
264 ctx := r.Context()
265 cancel := func() {}
266 if p.payment.RequestTimeout > 0 {
267 ctx, cancel = context.WithTimeout(ctx, p.payment.RequestTimeout)
268 }
269 defer cancel()
270
271 sender = suischeme.NormalizeAddress(sender)
272 if sender == "" {
273 http.Error(w, "sender is required", http.StatusBadRequest)
274 return
275 }
276 if p.requirements.Network == "" || p.requirements.Asset == "" {
277 http.Error(w, "payment is not configured", http.StatusInternalServerError)
278 return
279 }
280
281 coinObjects, err := suischeme.ListOwnedGaslessStablecoinCoinObjects(ctx, p.requirements.Network, sender, p.requirements.Asset, p.payment.Endpoints)
282 if err != nil {
283 http.Error(w, fmt.Sprintf("list USDC coin objects: %v", err), http.StatusBadGateway)
284 return
285 }
286 nonZeroCoinObjects := make([]suischeme.OwnedCoinObject, 0, len(coinObjects))
287 for _, coinObject := range coinObjects {
288 if coinObject.Balance == 0 {
289 continue
290 }
291 nonZeroCoinObjects = append(nonZeroCoinObjects, coinObject)
292 }
293
294 var prepareTransaction *struct {
295 Transaction string `json:"transaction"`
296 }
297 if len(nonZeroCoinObjects) > 0 {
298 txBytes, err := suischeme.BuildCoinObjectsToAddressBalanceTransferTransaction(ctx, suischeme.CoinObjectsToAddressBalanceTransfer{
299 Sender: sender,
300 Recipient: sender,
301 Network: p.requirements.Network,
302 Asset: p.requirements.Asset,
303 CoinObjects: nonZeroCoinObjects,
304 Endpoints: p.payment.Endpoints,
305 })
306 if err != nil {
307 http.Error(w, fmt.Sprintf("build prepare transaction: %v", err), http.StatusBadGateway)
308 return
309 }
310 prepareTransaction = &struct {
311 Transaction string `json:"transaction"`
312 }{Transaction: base64.StdEncoding.EncodeToString(txBytes)}
313 }
314
315 paymentTxBytes, err := suischeme.BuildGaslessStablecoinTransferTransaction(ctx, suischeme.GaslessStablecoinTransfer{
316 Sender: sender,
317 Recipient: p.requirements.PayTo,
318 Network: p.requirements.Network,
319 Asset: p.requirements.Asset,
320 Amount: p.requirements.Amount,
321 Endpoints: p.payment.Endpoints,
322 })
323 if err != nil {
324 http.Error(w, fmt.Sprintf("build payment transaction: %v", err), http.StatusBadGateway)
325 return
326 }
327
328 resourcePath = strings.TrimSpace(resourcePath)
329 if resourcePath == "" {
330 resourcePath = strings.TrimSpace(p.payment.ResourcePath)
331 }
332 if resourcePath == "" && r.URL != nil {
333 resourcePath = r.URL.Path
334 }
335 if resourcePath == "" {
336 resourcePath = "/"
337 }
338 resourceMimeType := strings.TrimSpace(p.payment.ResourceMimeType)
339 if resourceMimeType == "" {
340 resourceMimeType = "text/html"
341 }
342 utils.WritePaymentJSON(w, http.StatusOK, types.X402PreparePaymentResponse{
343 X402Version: int(facilitatortypes.X402VersionV2),
344 PaymentRequirements: p.requirements,
345 Resource: &facilitatortypes.ResourceInfo{
346 URL: utils.PublicURLForPath(r, resourcePath),
347 Description: strings.TrimSpace(p.payment.ResourceDescription),
348 MimeType: resourceMimeType,
349 },
350 PrepareTransaction: prepareTransaction,
351 PaymentTransaction: struct {
352 Transaction string `json:"transaction"`
353 }{Transaction: base64.StdEncoding.EncodeToString(paymentTxBytes)},
354 })
355 }