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
4 changes: 2 additions & 2 deletions examples/go/clients/batch-settlement/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Batch-Settlement Client (Go)

Go port of [`examples/typescript/clients/batch-settlement`](../../../typescript/clients/batch-settlement). Sequential batch-settlement payment client. Opens a payment channel on the first request (deposit) and pays subsequent requests with off-chain vouchers that update the cumulative claimable amount.
Sequential batch-settlement payment client. Opens a payment channel on the first request (deposit) and pays subsequent requests with off-chain vouchers that update the cumulative claimable amount.

## Run

Expand All @@ -11,7 +11,7 @@ cp .env-example .env
go run .
```

The companion server is in `examples/go/servers/batch-settlement` and the facilitator is in `examples/go/facilitator/batch-settlement`. The Go and TS clients share the same env keys, default route (`/weather`), and behavior — point the same `.env` at either binary.
The companion server is in `examples/go/servers/batch-settlement` and the facilitator is in `examples/go/facilitator/batch-settlement`.

## Voucher Signer Delegation

Expand Down
6 changes: 3 additions & 3 deletions examples/go/clients/batch-settlement/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func main() {
Salt: channelSalt,
}

// Optional dedicated voucher-signing key (matches TS EVM_VOUCHER_SIGNER_PRIVATE_KEY).
// Optional dedicated voucher-signing key.
if voucherKey := os.Getenv("EVM_VOUCHER_SIGNER_PRIVATE_KEY"); voucherKey != "" {
voucherSigner, err := evmsigners.NewClientSignerFromPrivateKey(voucherKey)
if err != nil {
Expand Down Expand Up @@ -171,7 +171,7 @@ func readJSON(resp *http.Response) (interface{}, error) {
return out, nil
}

func extractSettleResponse(resp *http.Response) (*map[string]interface{}, error) {
func extractSettleResponse(resp *http.Response) (*x402.SettleResponse, error) {
header := resp.Header.Get("PAYMENT-RESPONSE")
if header == "" {
header = resp.Header.Get("X-PAYMENT-RESPONSE")
Expand All @@ -183,7 +183,7 @@ func extractSettleResponse(resp *http.Response) (*map[string]interface{}, error)
if err != nil {
return nil, err
}
var out map[string]interface{}
var out x402.SettleResponse
if err := json.Unmarshal(decoded, &out); err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion examples/go/facilitator/batch-settlement/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ EVM_RPC_URL=https://sepolia.base.org
# Optional dedicated authorizer key (defaults to EVM_PRIVATE_KEY)
EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=

# Optional (default 4022). Same as examples/typescript/facilitator/batch-settlement.
# Optional listen port (default 4022).
PORT=
3 changes: 1 addition & 2 deletions examples/go/facilitator/batch-settlement/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ cp .env-example .env
go run .
```

Listens on `http://localhost:4022` by default (`PORT` overrides; same as the
TypeScript facilitator example).
Listens on `http://localhost:4022` by default (`PORT` overrides).

## Environment

Expand Down
16 changes: 2 additions & 14 deletions examples/go/servers/batch-settlement/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Batch-Settlement Server (Go)

Go port of [`examples/typescript/servers/batch-settlement`](../../../typescript/servers/batch-settlement). Demo resource server using the batch-settlement scheme: a client opens a payment channel with a single deposit; subsequent paid requests update an off-chain voucher. The `ChannelManager` periodically claims and settles onchain.
Demo resource server using the batch-settlement scheme: a client opens a payment channel with a single deposit; subsequent paid requests update an off-chain voucher. The `ChannelManager` periodically claims and settles onchain.

The route demonstrates **dynamic pricing**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `Settlement-Overrides`.

Expand All @@ -15,18 +15,6 @@ go run .

The server listens on `http://localhost:4021` and exposes `GET /weather`. Pair with `examples/go/clients/batch-settlement` and `examples/go/facilitator/batch-settlement`.

### Cross-SDK local testing

The Go and TS servers share the same `.env-example` keys, route (`GET /weather`), response shape (`{report: {weather, temperature}}`), and channel-manager cadences, so you can swap one for the other without changing client config:

```bash
# Go server
EVM_ADDRESS=0x... FACILITATOR_URL=http://localhost:4022 go run .

# TS server (same .env)
cd examples/typescript/servers/batch-settlement && pnpm dev
```

## Environment

| Variable | Required | Description |
Expand All @@ -39,7 +27,7 @@ cd examples/typescript/servers/batch-settlement && pnpm dev

## Auto-settlement

The example wires up a `ChannelManager` with the same triggers as the TS demo:
The example wires up a `ChannelManager` with simple local-demo triggers:

- **Claim** every 60 s.
- **Settle** every 120 s (sweeps claimed funds to `payTo`).
Expand Down
2 changes: 2 additions & 0 deletions examples/go/servers/batch-settlement/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)
2 changes: 2 additions & 0 deletions examples/go/servers/batch-settlement/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand Down
10 changes: 4 additions & 6 deletions examples/go/servers/batch-settlement/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import (
batchedserver "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement/server"
)

// Mirrors examples/typescript/servers/batch-settlement/index.ts.
const (
defaultPort = "4021"
network = x402.Network("eip155:84532")
Expand All @@ -43,7 +42,7 @@ func main() {
os.Exit(1)
}

// TS code default: 86400 (1 day). Falls back to that when env unset.
// Default channel withdraw delay is 1 day when the env var is unset.
withdrawDelay := 86400
if v := os.Getenv("DEFERRED_WITHDRAW_DELAY_SECONDS"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
Expand Down Expand Up @@ -83,7 +82,7 @@ func main() {
SettleIntervalSecs: 120,
RefundIntervalSecs: 180,
MaxClaimsPerBatch: 100,
// Refund channels after 3 minutes of inactivity (mirrors TS demo).
// Refund channels after 3 minutes of inactivity.
SelectRefundChannels: func(channels []*batchedserver.ChannelSession, ctx batchedserver.AutoSettlementContext) ([]*batchedserver.ChannelSession, error) {
out := make([]*batchedserver.ChannelSession, 0, len(channels))
for _, c := range channels {
Expand Down Expand Up @@ -114,7 +113,7 @@ func main() {
},
})

// SIGINT-only graceful shutdown (no SIGTERM), mirroring TS.
// Flush pending channel work during interactive shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT)

Expand All @@ -135,8 +134,7 @@ func main() {

mux := http.NewServeMux()
mux.HandleFunc("GET /weather", func(w http.ResponseWriter, r *http.Request) {
// Bill a random fraction of maxPrice (1–100%) to demonstrate
// usage-based pricing. Mirrors TS demo verbatim.
// Bill a random fraction of maxPrice (1-100%) to demonstrate usage-based pricing.
chargedPercent := 1 + rand.Intn(100)
nethttpmw.SetSettlementOverrides(w, &x402.SettlementOverrides{
Amount: fmt.Sprintf("%d%%", chargedPercent),
Expand Down
2 changes: 1 addition & 1 deletion go/SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ settleResult := httpServer.ProcessSettlement(ctx, payload, requirements, nil, &x
Request: &reqCtx,
ResponseBody: responseBody,
ResponseHeaders: responseHeaders,
})
}, nil)
```

### 4. Facilitator Client
Expand Down
37 changes: 37 additions & 0 deletions go/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,13 +529,50 @@ func decodePaymentRequiredHeader(header string) (x402.PaymentRequired, error) {

// encodePaymentResponseHeader encodes a settlement response as base64
func encodePaymentResponseHeader(response x402.SettleResponse) (string, error) {
response = withTypedChannelStateExtra(response)
data, err := json.Marshal(response)
if err != nil {
return "", fmt.Errorf("failed to marshal settle response: %w", err)
}
return base64.StdEncoding.EncodeToString(data), nil
}

type paymentResponseChannelStateExtra struct {
ChannelId string `json:"channelId,omitempty"`
Balance string `json:"balance,omitempty"`
TotalClaimed string `json:"totalClaimed,omitempty"`
WithdrawRequestedAt interface{} `json:"withdrawRequestedAt,omitempty"`
RefundNonce string `json:"refundNonce,omitempty"`
ChargedCumulativeAmount string `json:"chargedCumulativeAmount,omitempty"`
}

func withTypedChannelStateExtra(response x402.SettleResponse) x402.SettleResponse {
raw, ok := response.Extra["channelState"].(map[string]interface{})
if !ok {
return response
}

extra := make(map[string]interface{}, len(response.Extra))
for key, value := range response.Extra {
extra[key] = value
}
extra["channelState"] = paymentResponseChannelStateExtra{
ChannelId: stringField(raw, "channelId"),
Balance: stringField(raw, "balance"),
TotalClaimed: stringField(raw, "totalClaimed"),
WithdrawRequestedAt: raw["withdrawRequestedAt"],
RefundNonce: stringField(raw, "refundNonce"),
ChargedCumulativeAmount: stringField(raw, "chargedCumulativeAmount"),
}
response.Extra = extra
return response
}

func stringField(data map[string]interface{}, key string) string {
value, _ := data[key].(string)
return value
}

// decodePaymentResponseHeader decodes a base64 payment response header
func decodePaymentResponseHeader(header string) (*x402.SettleResponse, error) {
data, err := base64.StdEncoding.DecodeString(header)
Expand Down
49 changes: 49 additions & 0 deletions go/http/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,55 @@ func TestGetPaymentSettleResponse(t *testing.T) {
}
}

func TestEncodePaymentResponseHeader_ChannelStateOrder(t *testing.T) {
encoded, err := encodePaymentResponseHeader(x402.SettleResponse{
Success: true,
Payer: "0xpayer",
Transaction: "0xtx",
Network: "eip155:1",
Amount: "1000",
Extra: map[string]interface{}{
"channelState": map[string]interface{}{
"balance": "5000",
"channelId": "0xchan",
"chargedCumulativeAmount": "3000",
"refundNonce": "1",
"totalClaimed": "2000",
"withdrawRequestedAt": 0,
},
"chargedAmount": "1000",
},
})
if err != nil {
t.Fatalf("encodePaymentResponseHeader: %v", err)
}

decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
body := string(decoded)
fields := []string{
`"channelId":"0xchan"`,
`"balance":"5000"`,
`"totalClaimed":"2000"`,
`"withdrawRequestedAt":0`,
`"refundNonce":"1"`,
`"chargedCumulativeAmount":"3000"`,
}
last := -1
for _, field := range fields {
idx := strings.Index(body, field)
if idx == -1 {
t.Fatalf("missing %s in %s", field, body)
}
if idx <= last {
t.Fatalf("field %s out of order in %s", field, body)
}
last = idx
}
}

func TestPaymentRoundTripper(t *testing.T) {
// Create a test server that returns 402 first, then 200
callCount := 0
Expand Down
4 changes: 2 additions & 2 deletions go/http/echo/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ func handlePaymentVerified(c echo.Context, next echo.HandlerFunc, server *x402ht

// SkipHandler directive: bypass downstream handler, settle inline using the
// directive body. Used for refund acknowledgements where there is no resource
// response to return. Mirrors nethttp middleware behavior.
// response to return.
var err error
if result.SkipHandler != nil {
contentType := result.SkipHandler.ContentType
Expand Down Expand Up @@ -427,7 +427,7 @@ func handlePaymentVerified(c echo.Context, next echo.HandlerFunc, server *x402ht
return nil
}

settleResult := server.ProcessSettlementWithExtensions(
settleResult := server.ProcessSettlement(
ctx,
*result.PaymentPayload,
*result.PaymentRequirements,
Expand Down
4 changes: 2 additions & 2 deletions go/http/gin/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func handlePaymentVerified(c *gin.Context, server *x402http.HTTPServer, ctx cont

// SkipHandler directive: bypass downstream handler, settle inline using the
// directive body. Used for refund acknowledgements where there is no resource
// response to return. Mirrors nethttp middleware behavior.
// response to return.
skipHandler := result.SkipHandler != nil
if skipHandler {
contentType := result.SkipHandler.ContentType
Expand Down Expand Up @@ -430,7 +430,7 @@ func handlePaymentVerified(c *gin.Context, server *x402http.HTTPServer, ctx cont
return
}

settleResult := server.ProcessSettlementWithExtensions(
settleResult := server.ProcessSettlement(
ctx,
*result.PaymentPayload,
*result.PaymentRequirements,
Expand Down
2 changes: 1 addition & 1 deletion go/http/nethttp/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ func handlePaymentVerified(w http.ResponseWriter, r *http.Request, next http.Han
return
}

settleResult := server.ProcessSettlementWithExtensions(
settleResult := server.ProcessSettlement(
ctx,
*result.PaymentPayload,
*result.PaymentRequirements,
Expand Down
24 changes: 9 additions & 15 deletions go/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"log"
Expand Down Expand Up @@ -186,7 +187,7 @@ type HTTPProcessResult struct {
// `paymentRequiredResponse.extensions` flowing into both calls.
DeclaredExtensions map[string]interface{}
// SkipHandler is set when an AfterVerifyHook signals that the resource handler
// should be bypassed and settlement performed inline (e.g. cooperative refund).
// should be bypassed and settlement performed inline.
SkipHandler *x402.SkipHandlerDirective
// CancellationDispatcher fires onVerifiedPaymentCanceled hooks if the resource
// handler errors or returns a non-2xx status before settlement runs. Set when
Expand Down Expand Up @@ -615,14 +616,15 @@ func (s *x402HTTPResourceServer) ProcessHTTPRequest(ctx context.Context, reqCtx

// Verify payment (type-safe). Pass `extensions` so per-extension hooks
// (registered via ResourceServerExtensionHookProvider) gate on declared
// extension keys — mirrors TS `verifyPayment(..., declaredExtensions)`.
// extension keys.
verifyResp, verifyErr := s.VerifyPaymentWithExtensions(ctx, *typedPayload, *matchingReqs, extensions)
if verifyErr != nil {
err = verifyErr
// Prefer InvalidReason (the protocol error code) over the free-form
// message so enrichers can match on a stable identifier.
errorMsg := err.Error()
if ve, ok := verifyErr.(*x402.VerifyError); ok && ve.InvalidReason != "" {
var ve *x402.VerifyError
if errors.As(verifyErr, &ve) && ve.InvalidReason != "" {
errorMsg = ve.InvalidReason
}

Expand Down Expand Up @@ -692,23 +694,15 @@ func MarshalSettlementOverrides(overrides *x402.SettlementOverrides) string {
return string(data)
}

// ProcessSettlement handles settlement after successful response with no
// declared extensions. Equivalent to ProcessSettlementWithExtensions(...,
// nil). Kept for transport adapters that don't yet thread extensions
// through.
func (s *x402HTTPResourceServer) ProcessSettlement(ctx context.Context, payload types.PaymentPayload, requirements types.PaymentRequirements, overrides *x402.SettlementOverrides, transportContext *HTTPTransportContext) *ProcessSettleResult {
return s.ProcessSettlementWithExtensions(ctx, payload, requirements, overrides, transportContext, nil)
}

// ProcessSettlementWithExtensions handles settlement after successful response.
// ProcessSettlement handles settlement after successful response.
// If overrides is non-nil, it takes precedence. Otherwise, falls back to reading
// the settlement-overrides header from the transport context's ResponseHeaders
// (set by the route handler via SetSettlementOverrides). The header is deleted
// from ResponseHeaders to prevent it from being sent to the client.
//
// `declaredExtensions` is forwarded to SettlePaymentWithExtensions so per-
// extension settle hooks fire only when their key is declared on the route.
func (s *x402HTTPResourceServer) ProcessSettlementWithExtensions(
// declaredExtensions is forwarded to SettlePaymentWithExtensions so per-extension
// settle hooks fire only when their key is declared on the route.
func (s *x402HTTPResourceServer) ProcessSettlement(
ctx context.Context,
payload types.PaymentPayload,
requirements types.PaymentRequirements,
Expand Down
Loading
Loading