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
5 changes: 5 additions & 0 deletions examples/go/clients/authcapture/.env-example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# EVM private key for the paying wallet (hex, with or without 0x prefix)
EVM_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE

# URL of the x402-protected resource to fetch
SERVER_URL=http://localhost:8080/paid
6 changes: 6 additions & 0 deletions examples/go/clients/authcapture/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Environment variables
.env

# Built client binary (go build)
authcapture
authcapture.exe
64 changes: 64 additions & 0 deletions examples/go/clients/authcapture/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module github.com/x402-foundation/x402/examples/go/clients/authcapture

go 1.24.0

toolchain go1.24.1

replace github.com/x402-foundation/x402/go => ../../../../go

require (
github.com/joho/godotenv v1.5.1
github.com/x402-foundation/x402/go v0.0.0-00010101000000-000000000000
)

require (
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
github.com/bits-and-blooms/bitset v1.20.0 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/consensys/gnark-crypto v0.18.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-ethereum v1.16.7 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.0 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
go.mongodb.org/mongo-driver v1.12.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/ratelimit v0.2.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
201 changes: 201 additions & 0 deletions examples/go/clients/authcapture/go.sum

Large diffs are not rendered by default.

310 changes: 310 additions & 0 deletions examples/go/clients/authcapture/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
// Package main is a minimal x402 client that pays with the authCapture scheme.
//
// It first probes the endpoint (raw GET) to surface any 402 payment requirements,
// logs them with validation notes, then retries through the x402-aware HTTP client
// which signs and attaches the PAYMENT-SIGNATURE header automatically.
//
// Usage:
//
// cp .env-example .env # fill in your values
// go run .
//
// Or pass env vars directly:
//
// EVM_PRIVATE_KEY=0x... SERVER_URL=https://... go run .
package main

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

"github.com/joho/godotenv"
x402 "github.com/x402-foundation/x402/go"
x402http "github.com/x402-foundation/x402/go/http"
authcaptureclient "github.com/x402-foundation/x402/go/mechanisms/evm/authCapture/client"
evmsigners "github.com/x402-foundation/x402/go/signers/evm"
)

func main() {
_ = godotenv.Load()

privateKey := os.Getenv("EVM_PRIVATE_KEY")
if privateKey == "" {
fmt.Fprintln(os.Stderr, "error: EVM_PRIVATE_KEY is required")
os.Exit(1)
}

serverURL := os.Getenv("SERVER_URL")
if serverURL == "" {
fmt.Fprintln(os.Stderr, "error: SERVER_URL is required")
os.Exit(1)
}

signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid EVM_PRIVATE_KEY: %v\n", err)
os.Exit(1)
}

fmt.Printf("Wallet address: %s\n", signer.Address())
fmt.Printf("Target URL: %s\n\n", serverURL)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// ── Step 1: Probe the endpoint to inspect the 402 requirements ───────────
fmt.Println("━━━ Step 1: Probing endpoint (raw GET, no payment) ━━━")
raw402, err := probeEndpoint(ctx, serverURL)
if err != nil {
fmt.Fprintf(os.Stderr, "probe failed: %v\n", err)
os.Exit(1)
}

// ── Step 2: Attempt payment with the x402-aware client ───────────────────
fmt.Println("\n━━━ Step 2: Paying with authCapture scheme ━━━")

client := x402.Newx402Client()
client.Register("eip155:*", authcaptureclient.NewAuthCaptureEvmScheme(signer))

loggingTransport := &loggingRoundTripper{inner: http.DefaultTransport}
innerHTTPClient := &http.Client{Transport: loggingTransport}

httpClient := x402http.WrapHTTPClientWithPayment(
innerHTTPClient,
x402http.Newx402HTTPClient(client),
)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "error building request: %v\n", err)
os.Exit(1)
}

resp, err := httpClient.Do(req)
if err != nil {
// If the server returned a 402 that the client couldn't satisfy, the
// wrapped client surfaces a descriptive error here.
fmt.Fprintf(os.Stderr, "payment request failed: %v\n", err)
if raw402 != nil {
fmt.Fprintln(os.Stderr, "(see payment requirements logged above)")
}
os.Exit(1)
}
defer resp.Body.Close()

fmt.Printf("Final status: %s\n\n", resp.Status)

bodyBytes, _ := io.ReadAll(resp.Body)
var bodyJSON interface{}
if json.Unmarshal(bodyBytes, &bodyJSON) == nil {
pretty, _ := json.MarshalIndent(bodyJSON, "", " ")
fmt.Println("Response body:")
fmt.Println(string(pretty))
} else {
fmt.Printf("Response body (raw): %s\n", bodyBytes)
}

// Decode and display the PAYMENT-RESPONSE header if present
if ph := resp.Header.Get("PAYMENT-RESPONSE"); ph != "" {
fmt.Println("\n━━━ Payment Response ━━━")
decoded, decErr := base64.StdEncoding.DecodeString(ph)
if decErr == nil {
var pr interface{}
if json.Unmarshal(decoded, &pr) == nil {
pretty, _ := json.MarshalIndent(pr, "", " ")
fmt.Println(string(pretty))
} else {
fmt.Println(string(decoded))
}
} else {
fmt.Printf("PAYMENT-RESPONSE (raw): %s\n", ph)
}
}
}

// paymentRequired is a minimal struct for logging 402 body fields.
type paymentRequired struct {
X402Version int `json:"x402Version"`
Error string `json:"error"`
Resource map[string]interface{} `json:"resource"`
Accepts []map[string]interface{} `json:"accepts"`
Extensions map[string]interface{} `json:"extensions"`
}

// probeEndpoint sends an unauthenticated GET and, if the server responds with
// 402, logs and validates the payment requirements. Returns the parsed body
// (nil if not a 402).
func probeEndpoint(ctx context.Context, url string) (*paymentRequired, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return nil, fmt.Errorf("build probe request: %w", err)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("probe request: %w", err)
}
defer resp.Body.Close()

bodyBytes, _ := io.ReadAll(resp.Body)

fmt.Printf("Probe status: %s\n", resp.Status)
fmt.Printf("Response headers:\n")
for k, v := range resp.Header {
fmt.Printf(" %s: %s\n", k, v)
}
fmt.Printf("Raw body:\n%s\n\n", bodyBytes)

if resp.StatusCode != http.StatusPaymentRequired {
fmt.Printf("(non-402 response)\n")
return nil, nil
}

// Prefer the Payment-Required header (base64-encoded JSON) over the error body.
var pr paymentRequired
if hdr := resp.Header.Get("Payment-Required"); hdr != "" {
decoded, decErr := base64.StdEncoding.DecodeString(hdr)
if decErr == nil {
if err := json.Unmarshal(decoded, &pr); err != nil {
fmt.Printf("Payment-Required header (decode error): %v\n", err)
}
} else {
fmt.Printf("Payment-Required header (not base64): %s\n", hdr)
}
} else if err := json.Unmarshal(bodyBytes, &pr); err != nil {
fmt.Printf("402 body (not JSON): %s\n", bodyBytes)
return nil, nil
}

fmt.Printf("x402Version: %d\n", pr.X402Version)
if pr.Error != "" {
fmt.Printf("server error: %s\n", pr.Error)
}
if pr.Resource != nil {
if u, ok := pr.Resource["url"]; ok {
fmt.Printf("resource.url: %s\n", u)
}
}

fmt.Printf("\nPayment requirements (%d offer(s)):\n", len(pr.Accepts))
for i, req := range pr.Accepts {
fmt.Printf("\n [%d] scheme: %v\n", i, req["scheme"])
fmt.Printf(" network: %v\n", req["network"])
fmt.Printf(" asset: %v\n", req["asset"])
fmt.Printf(" amount: %v\n", req["amount"])
fmt.Printf(" payTo: %v\n", req["payTo"])
fmt.Printf(" maxTimeoutSeconds: %v\n", req["maxTimeoutSeconds"])

extra, _ := req["extra"].(map[string]interface{})
if extra != nil {
fmt.Printf(" extra:\n")
extraJSON, _ := json.MarshalIndent(extra, " ", " ")
fmt.Printf(" %s\n", extraJSON)

// ── Validation checks ────────────────────────────────────────────
fmt.Printf(" validation:\n")
issues := validateAuthCaptureExtra(req["scheme"], extra)
if len(issues) == 0 {
fmt.Printf(" ✓ all required authCapture extra fields present\n")
} else {
for _, iss := range issues {
fmt.Printf(" ✗ %s\n", iss)
}
}
} else {
fmt.Printf(" extra: (none)\n")
fmt.Printf(" validation: ✗ missing extra entirely\n")
}
}

if len(pr.Accepts) == 0 {
fmt.Println(" (no offers — cannot pay)")
}

return &pr, nil
}

// loggingRoundTripper logs outgoing request headers (especially PAYMENT-SIGNATURE)
// and response status before forwarding.
type loggingRoundTripper struct {
inner http.RoundTripper
}

func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
sig := req.Header.Get("PAYMENT-SIGNATURE")
if sig != "" {
fmt.Println("\n━━━ Outgoing PAYMENT-SIGNATURE ━━━")
decoded, err := base64.StdEncoding.DecodeString(sig)
if err == nil {
var obj interface{}
if json.Unmarshal(decoded, &obj) == nil {
pretty, _ := json.MarshalIndent(obj, "", " ")
fmt.Println(string(pretty))
} else {
fmt.Println(string(decoded))
}
} else {
fmt.Printf("(raw, not base64): %s\n", sig)
}
fmt.Println()
}
resp, err := l.inner.RoundTrip(req)
if resp != nil {
fmt.Printf("→ server responded: %s\n", resp.Status)
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
resp.Body = io.NopCloser(strings.NewReader(string(body)))
fmt.Printf(" error body: %s\n\n", body)
}
}
return resp, err
}

// validateAuthCaptureExtra checks that all required authCapture extra fields
// are present and returns a list of human-readable problems.
func validateAuthCaptureExtra(scheme interface{}, extra map[string]interface{}) []string {
if scheme != "authCapture" {
return []string{fmt.Sprintf("scheme is %q (expected authCapture)", scheme)}
}

required := []string{
"captureAuthorizer",
"captureDeadline",
"refundDeadline",
"feeRecipient",
"minFeeBps",
"maxFeeBps",
}

var issues []string
for _, field := range required {
if _, ok := extra[field]; !ok {
issues = append(issues, fmt.Sprintf("missing field: %s", field))
}
}

// Deadline ordering
cd, cdOK := extra["captureDeadline"].(float64)
rd, rdOK := extra["refundDeadline"].(float64)
if cdOK && rdOK && rd < cd {
issues = append(issues, fmt.Sprintf(
"refundDeadline (%v) < captureDeadline (%v) — invalid ordering", rd, cd,
))
}

// EIP-712 domain fields needed for signing
for _, field := range []string{"name", "version"} {
if v, ok := extra[field]; !ok || v == "" {
issues = append(issues, fmt.Sprintf("missing EIP-712 domain field: %s", field))
}
}

return issues
}
Loading
Loading