Skip to content
Draft
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 @@ -55,6 +55,12 @@ go get github.com/coinbase/x402/go
- **Trust minimizing:** all payment schemes must not allow for the facilitator or resource server to move funds, other than in accordance with client intentions
- **Easy to use:** It is the goal of the x402 community to improve ease of use relative to other forms of payment on the Internet. This means abstracting as many details of crypto as possible away from the client and resource server, and into the facilitator. This means the client/server should not need to think about gas, rpc, etc.

## Security Best Practices

- Check counterparty risk before sending payment. x402 verifies payment mechanics, but it does not attest that a service is trustworthy or that the recipient is safe to pay.
- Consider screening the resource server domain, IP, payout wallet, and operator identity before funding autonomous requests, especially for sanctions compliance and fraud prevention.
- Treat payment as only one part of trust. Clients should still validate response integrity, reputation, and any application-specific safety signals before continuing automated workflows.

## Ecosystem

The x402 ecosystem is growing! Check out our [ecosystem page](https://x402.org/ecosystem) to see projects building with x402, including:
Expand Down
3 changes: 3 additions & 0 deletions go/.changes/unreleased/fixed-20260329-1845-574.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: fixed
body: Harden SVM facilitator fee-payer selection with crypto/rand and omit empty failed-settlement transaction hashes from Go settlement responses
time: 2026-03-29T12:00:00-07:00
11 changes: 11 additions & 0 deletions go/http/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,17 @@ func TestProcessSettlement_Failure(t *testing.T) {
if result.Headers == nil || result.Headers["PAYMENT-RESPONSE"] == "" {
t.Error("Expected PAYMENT-RESPONSE header on settlement failure")
}
decodedHeader, err := base64.StdEncoding.DecodeString(result.Headers["PAYMENT-RESPONSE"])
if err != nil {
t.Fatalf("failed to decode PAYMENT-RESPONSE header: %v", err)
}
var paymentResponse map[string]interface{}
if err := json.Unmarshal(decodedHeader, &paymentResponse); err != nil {
t.Fatalf("failed to unmarshal PAYMENT-RESPONSE header: %v", err)
}
if _, exists := paymentResponse["transaction"]; exists {
t.Errorf("expected failed settlement response to omit transaction, got %v", paymentResponse["transaction"])
}
if result.Response == nil {
t.Fatal("Expected Response to be set on settlement failure")
}
Expand Down
44 changes: 44 additions & 0 deletions go/mechanisms/svm/exact/facilitator/duplicate_tx_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package facilitator

import (
"context"
"testing"
"time"

x402 "github.com/coinbase/x402/go"
"github.com/coinbase/x402/go/mechanisms/svm"
solana "github.com/gagliardetto/solana-go"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -86,3 +89,44 @@ func TestDuplicateSettlementCache(t *testing.T) {
"scheme should hold the exact cache instance that was injected")
})
}

func TestGetExtraReturnsManagedFeePayer(t *testing.T) {
addresses := []solana.PublicKey{
solana.MustPublicKeyFromBase58(svm.MemoProgramAddress),
solana.MustPublicKeyFromBase58(svm.LighthouseProgramAddress),
}
scheme := NewExactSvmScheme(mockFacilitatorSvmSigner{addresses: addresses})

extra := scheme.GetExtra(x402.Network("solana:mainnet"))

feePayer, ok := extra["feePayer"].(string)
if !ok {
t.Fatalf("expected feePayer string, got %T", extra["feePayer"])
}

assert.Contains(t, []string{addresses[0].String(), addresses[1].String()}, feePayer)
}

type mockFacilitatorSvmSigner struct {
addresses []solana.PublicKey
}

func (m mockFacilitatorSvmSigner) GetAddresses(context.Context, string) []solana.PublicKey {
return m.addresses
}

func (mockFacilitatorSvmSigner) SignTransaction(context.Context, *solana.Transaction, solana.PublicKey, string) error {
return nil
}

func (mockFacilitatorSvmSigner) SimulateTransaction(context.Context, *solana.Transaction, string) error {
return nil
}

func (mockFacilitatorSvmSigner) SendTransaction(context.Context, *solana.Transaction, string) (solana.Signature, error) {
return solana.Signature{}, nil
}

func (mockFacilitatorSvmSigner) ConfirmTransaction(context.Context, solana.Signature, string) error {
return nil
}
21 changes: 19 additions & 2 deletions go/mechanisms/svm/exact/facilitator/scheme.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package facilitator

import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/rand"
"math/big"
"strconv"

solana "github.com/gagliardetto/solana-go"
Expand Down Expand Up @@ -53,15 +54,31 @@ func (f *ExactSvmScheme) CaipFamily() string {
// Random selection distributes load across multiple signers.
func (f *ExactSvmScheme) GetExtra(network x402.Network) map[string]interface{} {
addresses := f.signer.GetAddresses(context.Background(), string(network))
if len(addresses) == 0 {
return map[string]interface{}{}
}

// Randomly select from available addresses to distribute load
randomIndex := rand.Intn(len(addresses))
randomIndex := randomAddressIndex(len(addresses))

return map[string]interface{}{
"feePayer": addresses[randomIndex].String(),
}
}

func randomAddressIndex(addressCount int) int {
if addressCount <= 1 {
return 0
}

randomValue, err := rand.Int(rand.Reader, big.NewInt(int64(addressCount)))
if err != nil {
return 0
}

return int(randomValue.Int64())
}

// GetSigners returns signer addresses used by this facilitator.
// For SVM, returns all available fee payer addresses for the given network.
func (f *ExactSvmScheme) GetSigners(network x402.Network) []string {
Expand Down
44 changes: 44 additions & 0 deletions go/mechanisms/svm/exact/v1/facilitator/duplicate_tx_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package facilitator

import (
"context"
"testing"
"time"

x402 "github.com/coinbase/x402/go"
"github.com/coinbase/x402/go/mechanisms/svm"
solana "github.com/gagliardetto/solana-go"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -114,3 +117,44 @@ func TestDuplicateSettlementCacheV1(t *testing.T) {
assert.True(t, cache.IsDuplicate("new-3"), "fresh entry should still be cached")
})
}

func TestGetExtraReturnsManagedFeePayerV1(t *testing.T) {
addresses := []solana.PublicKey{
solana.MustPublicKeyFromBase58(svm.MemoProgramAddress),
solana.MustPublicKeyFromBase58(svm.LighthouseProgramAddress),
}
scheme := NewExactSvmSchemeV1(mockFacilitatorSvmSignerV1{addresses: addresses})

extra := scheme.GetExtra(x402.Network("solana:mainnet"))

feePayer, ok := extra["feePayer"].(string)
if !ok {
t.Fatalf("expected feePayer string, got %T", extra["feePayer"])
}

assert.Contains(t, []string{addresses[0].String(), addresses[1].String()}, feePayer)
}

type mockFacilitatorSvmSignerV1 struct {
addresses []solana.PublicKey
}

func (m mockFacilitatorSvmSignerV1) GetAddresses(context.Context, string) []solana.PublicKey {
return m.addresses
}

func (mockFacilitatorSvmSignerV1) SignTransaction(context.Context, *solana.Transaction, solana.PublicKey, string) error {
return nil
}

func (mockFacilitatorSvmSignerV1) SimulateTransaction(context.Context, *solana.Transaction, string) error {
return nil
}

func (mockFacilitatorSvmSignerV1) SendTransaction(context.Context, *solana.Transaction, string) (solana.Signature, error) {
return solana.Signature{}, nil
}

func (mockFacilitatorSvmSignerV1) ConfirmTransaction(context.Context, solana.Signature, string) error {
return nil
}
21 changes: 19 additions & 2 deletions go/mechanisms/svm/exact/v1/facilitator/scheme.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package facilitator

import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/rand"
"math/big"
"strconv"

solana "github.com/gagliardetto/solana-go"
Expand Down Expand Up @@ -54,15 +55,31 @@ func (f *ExactSvmSchemeV1) CaipFamily() string {
// Random selection distributes load across multiple signers.
func (f *ExactSvmSchemeV1) GetExtra(network x402.Network) map[string]interface{} {
addresses := f.signer.GetAddresses(context.Background(), string(network))
if len(addresses) == 0 {
return map[string]interface{}{}
}

// Randomly select from available addresses to distribute load
randomIndex := rand.Intn(len(addresses))
randomIndex := randomAddressIndex(len(addresses))

return map[string]interface{}{
"feePayer": addresses[randomIndex].String(),
}
}

func randomAddressIndex(addressCount int) int {
if addressCount <= 1 {
return 0
}

randomValue, err := rand.Int(rand.Reader, big.NewInt(int64(addressCount)))
if err != nil {
return 0
}

return int(randomValue.Int64())
}

// GetSigners returns signer addresses used by this facilitator.
// For SVM, returns all available fee payer addresses for the given network.
func (f *ExactSvmSchemeV1) GetSigners(network x402.Network) []string {
Expand Down
2 changes: 1 addition & 1 deletion go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ type SettleResponse struct {
ErrorReason string `json:"errorReason,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Payer string `json:"payer,omitempty"`
Transaction string `json:"transaction"`
Transaction string `json:"transaction,omitempty"`
Network Network `json:"network"`
}

Expand Down
1 change: 1 addition & 0 deletions python/x402/changelog.d/574.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Normalize failed settlement responses to omit unavailable transaction hashes instead of serializing empty strings.
13 changes: 10 additions & 3 deletions python/x402/schemas/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Any

from pydantic import Field
from pydantic import Field, field_validator

from .base import BaseX402Model, Network
from .payments import PaymentPayload, PaymentRequirements
Expand Down Expand Up @@ -60,17 +60,24 @@ class SettleResponse(BaseX402Model):
error_reason: Reason for failure (if success is False).
error_message: Human-readable message for failure.
payer: The payer's address.
transaction: Transaction hash/identifier.
transaction: Transaction hash/identifier when settlement succeeds.
network: Network where settlement occurred.
"""

success: bool
error_reason: str | None = None
error_message: str | None = None
payer: str | None = None
transaction: str
transaction: str | None = None
network: Network

@field_validator("payer", "transaction", mode="before")
@classmethod
def normalize_optional_strings(cls, value: str | None) -> str | None:
if value == "":
return None
return value


class SupportedKind(BaseX402Model):
"""A supported payment configuration.
Expand Down
5 changes: 4 additions & 1 deletion python/x402/tests/unit/http/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,17 @@ def test_decode_failed_settle_response(self):
settle = SettleResponse(
success=False,
error_reason="Insufficient funds",
transaction="",
network="eip155:8453",
)
encoded = encode_payment_response_header(settle)
data = json.loads(safe_base64_decode(encoded))
decoded = decode_payment_response_header(encoded)

assert decoded.success is False
assert decoded.error_reason == "Insufficient funds"
assert decoded.transaction is None
assert "transaction" not in data
assert "payer" not in data


class TestDetectPaymentRequiredVersion:
Expand Down
2 changes: 1 addition & 1 deletion specs/transports-v1/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ The base64 response header decodes to:
```http
HTTP/1.1 402 Payment Required
Content-Type: application/json
X-PAYMENT-RESPONSE: eyJzdWNjZXNzIjpmYWxzZSwiZXJyb3JSZWFzb24iOiJpbnN1ZmZpY2llbnRfZnVuZHMiLCJ0cmFuc2FjdGlvbiI6IiIsIm5ldHdvcmsiOiJiYXNlLXNlcG9saWEiLCJwYXllciI6IjB4ODU3YjA2NTE5RTkxZTNBNTQ1Mzg3OTFiRGJiMEUyMjM3M2UzNmI2NiJ9
X-PAYMENT-RESPONSE: eyJzdWNjZXNzIjpmYWxzZSwiZXJyb3JSZWFzb24iOiJpbnN1ZmZpY2llbnRfZnVuZHMiLCJuZXR3b3JrIjoiYmFzZS1zZXBvbGlhIn0=

{
"x402Version": 1,
Expand Down
6 changes: 2 additions & 4 deletions specs/transports-v2/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ The base64 response header decodes to:
```http
HTTP/1.1 402 Payment Required
Content-Type: application/json
PAYMENT-RESPONSE: eyJzdWNjZXNzIjpmYWxzZSwiZXJyb3JSZWFzb24iOiJpbnN1ZmZpY2llbnRfZnVuZHMiLCJ0cmFuc2FjdGlvbiI6IiIsIm5ldHdvcmsiOiJlaXAxNTU6ODQ1MzIiLCJwYXllciI6IjB4ODU3YjA2NTE5RTkxZTNBNTQ1Mzg3OTFiRGJiMEUyMjM3M2UzNmI2NiJ9
PAYMENT-RESPONSE: eyJzdWNjZXNzIjpmYWxzZSwiZXJyb3JSZWFzb24iOiJpbnN1ZmZpY2llbnRfZnVuZHMiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMyIn0=

{}
```
Expand All @@ -152,9 +152,7 @@ The base64 response header decodes to:
{
"success": false,
"errorReason": "insufficient_funds",
"transaction": "",
"network": "eip155:84532",
"payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66"
"network": "eip155:84532"
}
```

Expand Down
6 changes: 2 additions & 4 deletions specs/x402-specification-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ The `SettlementResponse` schema contains the following fields:
| ------------- | --------- | -------- | --------------------------------------------------------------- |
| `success` | `boolean` | Required | Indicates whether the payment settlement was successful |
| `errorReason` | `string` | Optional | Error reason if settlement failed (omitted if successful) |
| `transaction` | `string` | Required | Blockchain transaction hash (empty string if settlement failed) |
| `transaction` | `string` | Optional | Blockchain transaction hash when settlement succeeds |
| `network` | `string` | Required | Blockchain network identifier |
| `payer` | `string` | Required | Address of the payer's wallet |
| `payer` | `string` | Optional | Address of the payer's wallet when it can be determined |

**6. Payment Schemes (The Logic)**

Expand Down Expand Up @@ -371,8 +371,6 @@ Executes a verified payment by broadcasting the transaction to the blockchain.
{
"success": false,
"errorReason": "insufficient_funds",
"payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66",
"transaction": "",
"network": "base-sepolia"
}
```
Expand Down
6 changes: 2 additions & 4 deletions specs/x402-specification-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ The `SettleResponse` schema contains the following fields:
| ------------- | --------- | -------- | --------------------------------------------------------------------- |
| `success` | `boolean` | Required | Indicates whether the payment settlement was successful |
| `errorReason` | `string` | Optional | Error reason if settlement failed (omitted if successful) |
| `payer` | `string` | Optional | Address of the payer's wallet |
| `transaction` | `string` | Required | Blockchain transaction hash (empty string if settlement failed) |
| `payer` | `string` | Optional | Address of the payer's wallet when it can be determined |
| `transaction` | `string` | Optional | Blockchain transaction hash when settlement succeeds |
| `network` | `string` | Required | Blockchain network identifier in CAIP-2 format |
| `extensions` | `object` | Optional | Protocol extensions data |

Expand Down Expand Up @@ -428,8 +428,6 @@ Executes a verified payment by broadcasting the transaction to the blockchain.
{
"success": false,
"errorReason": "insufficient_funds",
"payer": "0x857b06519E91e3A54538791bDbb0E22373e36b66",
"transaction": "",
"network": "eip155:84532"
}
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@x402/core": patch
---

Normalize failed settlement responses to omit unavailable transaction hashes and preserve paywall current URLs with query parameters
7 changes: 5 additions & 2 deletions typescript/packages/core/src/http/httpFacilitatorClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ const settleResponseSchema: z.ZodType<SettleResponse, z.ZodTypeDef, unknown> = z
payer: z
.string()
.nullish()
.transform(v => v ?? undefined),
transaction: z.string(),
.transform(v => (v && v.length > 0 ? v : undefined)),
transaction: z
.string()
.nullish()
.transform(v => (v && v.length > 0 ? v : undefined)),
network: z.custom<SettleResponse["network"]>(value => typeof value === "string"),
extensions: z
.record(z.string(), z.unknown())
Expand Down
Loading
Loading