Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,12 @@ When several balance assets match, a fresh account balance lookup can select an
an unavailable balance lookup falls back to the first match. InFlow remains the authority on
available funds. Managed signing does not support Permit2.

Circle Gateway offers (`extra.name: "GatewayWalletBatched"`) are excluded from both managed and
external-wallet selection. When every offer requires Gateway, `Sign` and `Do` return a
`*buyer.Error` with code `unsupported-capability` explaining that InFlow does not support Circle Gateway.
`Prepare` rejects a Gateway offer before creating an approval. Other offers in the same response
remain eligible for selection.

For instrument payments, set `buyer.Options.InstrumentID` to an owned card's identifier.
Leave it empty to use the account's primary card. A rejected selection fails without trying a
different card. This option has no effect on balance or blockchain payments. The selection belongs
Expand Down
14 changes: 12 additions & 2 deletions x402/buyer/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,18 @@ type EncodedPayment struct {

type Error struct {
Code string
Message string
Status string
ApprovalID string
Cause error
}

func (e *Error) Error() string { return "x402 " + e.Code }
func (e *Error) Error() string {
if e.Message != "" {
return e.Message
}
return "x402 " + e.Code
}
func (e *Error) Unwrap() error { return e.Cause }

type Client struct {
Expand Down Expand Up @@ -189,7 +195,7 @@ func (c *Client) Supported(ctx context.Context) (x402.BuyerSupportedResponse, er
}

func supports(supported x402.BuyerSupportedResponse, requirement x402.PaymentRequirements) bool {
if requirement.Extra["assetTransferMethod"] == "permit2" {
if isCircleGateway(requirement) || requirement.Extra["assetTransferMethod"] == "permit2" {
return false
}
for _, kind := range supported.Kinds {
Expand All @@ -200,6 +206,10 @@ func supports(supported x402.BuyerSupportedResponse, requirement x402.PaymentReq
return false
}

func isCircleGateway(requirement x402.PaymentRequirements) bool {
return requirement.Extra["name"] == "GatewayWalletBatched"
}

func (c *Client) before(ctx context.Context, required x402.PaymentRequired) error {
for _, hook := range c.options.Hooks {
if hook.Before != nil {
Expand Down
218 changes: 218 additions & 0 deletions x402/buyer/gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package buyer_test

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"sync/atomic"
"testing"

inflow "github.com/inflowpayai/inflow-go"
"github.com/inflowpayai/inflow-go/x402"
"github.com/inflowpayai/inflow-go/x402/buyer"
foundation "github.com/x402-foundation/x402/go/v2"
)

func gatewayOffer() x402.PaymentRequirements {
return x402.PaymentRequirements{Scheme: "exact", Network: "eip155:8453", Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Amount: "1", PayTo: "seller", MaxTimeoutSeconds: 604900, Extra: map[string]any{"name": "GatewayWalletBatched", "version": "1"}}
}

func ordinaryOffer() x402.PaymentRequirements {
r := gatewayOffer()
r.MaxTimeoutSeconds = 300
r.Extra = map[string]any{"name": "USD Coin", "version": "2"}
return r
}

func gatewayPlatform(t *testing.T, managed bool) (*httptest.Server, *atomic.Int32) {
t.Helper()
creates := new(atomic.Int32)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/transactions/x402-supported":
kinds := []x402.SupportedKind{}
if managed {
kinds = append(kinds, x402.SupportedKind{Scheme: "exact", Network: "eip155:8453", X402Version: 2})
}
_ = json.NewEncoder(w).Encode(x402.BuyerSupportedResponse{Kinds: kinds})
case "/v1/transactions/x402":
creates.Add(1)
var body struct {
Accept x402.PaymentRequirements `json:"accept"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || !reflect.DeepEqual(body.Accept, ordinaryOffer()) {
t.Errorf("unexpected payment requirement: %+v, %v", body.Accept, err)
}
fmt.Fprint(w, `{"transactionId":"txn","approvalId":"approval","approvalStatus":"APPROVED"}`)
case "/v1/transactions/txn/x402":
payload := x402.PaymentPayload{X402Version: 2, Accepted: ordinaryOffer(), Payload: map[string]any{"signature": "synthetic"}}
data, _ := json.Marshal(payload)
_ = json.NewEncoder(w).Encode(map[string]any{"status": "SETTLED", "encodedPayload": base64.StdEncoding.EncodeToString(data), "paymentPayload": payload})
default:
t.Errorf("unexpected request %s", r.URL)
w.WriteHeader(500)
}
}))
t.Cleanup(s.Close)
return s, creates
}

func assertGatewayError(t *testing.T, err error) {
t.Helper()
var failure *buyer.Error
if !errors.As(err, &failure) || failure.Code != "unsupported-capability" || err.Error() != "Circle Gateway payments are not supported by InFlow." {
t.Fatalf("expected Gateway rejection, got %v", err)
}
}

func TestGatewayDirectPreparationAndSigning(t *testing.T) {
for _, timeout := range []int{300, 3600, 604900} {
t.Run(fmt.Sprint(timeout), func(t *testing.T) {
s, creates := gatewayPlatform(t, true)
c := client(t, s)
r := required()
r.Accepts = []x402.PaymentRequirements{gatewayOffer()}
r.Accepts[0].MaxTimeoutSeconds = timeout
if selected, err := c.Select(context.Background(), r); err != nil || selected != nil {
t.Fatalf("managed Gateway selection: %+v, %v", selected, err)
}
_, err := c.Prepare(context.Background(), r, buyer.SignOptions{})
assertGatewayError(t, err)
_, err = c.Sign(context.Background(), r, buyer.SignOptions{})
assertGatewayError(t, err)
if creates.Load() != 0 {
t.Fatal("created a Gateway transaction")
}
})
}
}

func TestGatewayMixedAndExternalRouting(t *testing.T) {
for _, managed := range []bool{false, true} {
for _, mixed := range []bool{false, true} {
t.Run(fmt.Sprintf("managed=%t/mixed=%t", managed, mixed), func(t *testing.T) {
s, creates := gatewayPlatform(t, managed)
calls := new(atomic.Int32)
external := foundation.Newx402Client(foundation.WithSpendControls(foundation.SpendControls{AllowedAssets: []foundation.SpendControlAsset{{Network: "eip155:8453", Asset: gatewayOffer().Asset, MaxAmountPerPayment: "1"}}})).Register("eip155:8453", signer{calls})
c, err := buyer.New(buyer.Options{Options: inflow.Options{BaseURL: s.URL}, External: external})
if err != nil {
t.Fatal(err)
}
r := required()
r.Accepts = []x402.PaymentRequirements{gatewayOffer()}
if mixed {
r.Accepts = append(r.Accepts, ordinaryOffer())
}
original, _ := json.Marshal(r)
payment, err := c.Sign(context.Background(), r, buyer.SignOptions{})
if !mixed {
assertGatewayError(t, err)
} else if err != nil || !reflect.DeepEqual(payment.PaymentPayload.Accepted, ordinaryOffer()) {
t.Fatalf("ordinary offer not paid: %+v, %v", payment, err)
}
var wantCreates, wantSigns int32
if mixed {
if managed {
wantCreates = 1
} else {
wantSigns = 1
}
}
if creates.Load() != wantCreates || calls.Load() != wantSigns {
t.Fatalf("creates=%d signs=%d", creates.Load(), calls.Load())
}
after, _ := json.Marshal(r)
if string(original) != string(after) {
t.Fatal("mutated caller response")
}
})
}
}
}

func TestGatewayPolicyReplacement(t *testing.T) {
for _, managed := range []bool{false, true} {
t.Run(fmt.Sprint(managed), func(t *testing.T) {
s, creates := gatewayPlatform(t, managed)
c, err := buyer.New(buyer.Options{Options: inflow.Options{BaseURL: s.URL}, External: externalStub{}, Policies: []buyer.Policy{func(context.Context, []x402.PaymentRequirements) ([]x402.PaymentRequirements, error) {
return []x402.PaymentRequirements{gatewayOffer()}, nil
}}})
if err != nil {
t.Fatal(err)
}
r := required()
r.Accepts = []x402.PaymentRequirements{ordinaryOffer()}
_, err = c.Sign(context.Background(), r, buyer.SignOptions{})
if managed {
var failure *buyer.Error
if !errors.As(err, &failure) || failure.Code != "unsupported-capability" {
t.Fatal(err)
}
} else {
assertGatewayError(t, err)
}
if creates.Load() != 0 {
t.Fatal("created a transaction after policy rejection")
}
})
}
}

func TestGatewayDoesNotMaskOtherUnsupportedOffers(t *testing.T) {
s, _ := gatewayPlatform(t, false)
c := client(t, s)
for _, accepts := range [][]x402.PaymentRequirements{nil, {gatewayOffer(), ordinaryOffer()}} {
r := required()
r.Accepts = accepts
_, err := c.Sign(context.Background(), r, buyer.SignOptions{})
var failure *buyer.Error
if !errors.As(err, &failure) || failure.Code != "unsupported-capability" || err.Error() != "x402 unsupported-capability" {
t.Fatal(err)
}
}
}

func TestGatewayNameIsExact(t *testing.T) {
s, _ := gatewayPlatform(t, true)
c := client(t, s)
for _, extra := range []map[string]any{nil, {}, {"assetName": "GatewayWalletBatched"}, {"name": "USD Coin"}, {"name": "gatewaywalletbatched"}, {"name": map[string]any{"value": "GatewayWalletBatched"}}} {
r := required()
r.Accepts = []x402.PaymentRequirements{ordinaryOffer()}
r.Accepts[0].Extra = extra
if selected, err := c.Select(context.Background(), r); err != nil || selected == nil {
t.Fatalf("metadata rejected: %+v, %v", extra, err)
}
}
}

func TestGatewayHTTPDoesNotReplay(t *testing.T) {
s, creates := gatewayPlatform(t, true)
c := client(t, s)
r := required()
r.Accepts = []x402.PaymentRequirements{gatewayOffer()}
data, _ := json.Marshal(r)
var requests atomic.Int32
merchant := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get(x402.HeaderPaymentSignature) != "" {
t.Error("Gateway payment sent")
}
w.Header().Set(x402.HeaderPaymentRequired, base64.StdEncoding.EncodeToString(data))
w.WriteHeader(http.StatusPaymentRequired)
}))
defer merchant.Close()
request, err := http.NewRequest(http.MethodGet, merchant.URL, nil)
if err != nil {
t.Fatal(err)
}
_, err = c.Do(request, buyer.SignOptions{})
assertGatewayError(t, err)
if requests.Load() != 1 || creates.Load() != 0 {
t.Fatalf("requests=%d creates=%d", requests.Load(), creates.Load())
}
}
3 changes: 3 additions & 0 deletions x402/buyer/payment.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func (c *Client) prepare(ctx context.Context, required x402.PaymentRequired, opt
if err != nil {
return nil, err
}
if isCircleGateway(required.Accepts[0]) {
return nil, &Error{Code: "unsupported-capability", Message: "Circle Gateway payments are not supported by InFlow."}
}
supported, err := c.Supported(ctx)
if err != nil {
return nil, err
Expand Down
14 changes: 13 additions & 1 deletion x402/buyer/selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,29 @@ func (c *Client) Sign(ctx context.Context, required x402.PaymentRequired, option
}
return value, nil
}
candidates := make([]x402.PaymentRequirements, 0, len(required.Accepts))
for _, r := range required.Accepts {
if !isCircleGateway(r) {
candidates = append(candidates, r)
}
}
if len(candidates) == 0 && len(required.Accepts) > 0 {
return EncodedPayment{}, &Error{Code: "unsupported-capability", Message: "Circle Gateway payments are not supported by InFlow."}
}
if c.options.External == nil {
return EncodedPayment{}, &Error{Code: "unsupported-capability"}
}
candidates, err := c.policies(ctx, required.Accepts)
candidates, err = c.policies(ctx, candidates)
if err != nil {
return EncodedPayment{}, err
}
chosen, err := c.options.External.SelectPaymentRequirements(candidates)
if err != nil {
return EncodedPayment{}, err
}
if isCircleGateway(chosen) {
return EncodedPayment{}, &Error{Code: "unsupported-capability", Message: "Circle Gateway payments are not supported by InFlow."}
}
required.Accepts = []x402.PaymentRequirements{chosen}
if err = c.before(ctx, required); err != nil {
return EncodedPayment{}, err
Expand Down
Loading