x402-rails 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +189 -9
- data/lib/generators/x402/install/install_generator.rb +30 -0
- data/lib/{x402/rails/generators → generators/x402/install}/templates/x402_initializer.rb +9 -0
- data/lib/x402/cdp_facilitator_auth.rb +113 -0
- data/lib/x402/chains.rb +16 -0
- data/lib/x402/configuration.rb +10 -1
- data/lib/x402/discovery_extension.rb +127 -0
- data/lib/x402/facilitator_client.rb +45 -36
- data/lib/x402/payment_payload.rb +4 -2
- data/lib/x402/rails/controller_extensions.rb +93 -13
- data/lib/x402/rails/version.rb +1 -1
- data/lib/x402/rails.rb +2 -0
- metadata +20 -4
- data/lib/x402/rails/generators/install_generator.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ec0c7a3137f3d98d641403e48438dc35677587631197581e3574804df7f42fa
|
|
4
|
+
data.tar.gz: f1d3d3ed62e675ad0c818e0202066c95cdddbd2c13ce6be3cec9ef04c1a47e94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 888362993251583ffdf727c493468dc65ae6c97cff5017983b4b47675d80eed7ce2165a99fd78746d07a495d1a0ffa1dd641a2a4d0c36bb3ab5f5e55b5556899
|
|
7
|
+
data.tar.gz: dec329b07321b5c705a38ace0a90d5c9599f34eeb0f7fcb8b6f8b6ff4c51febe986c06da450486b31131252886145a3c00dedaeb2765d20cc8860bee84960c65
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.0] - Unreleased
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Bazaar discovery** - `x402_discovery` controller macro declares discovery metadata per action; the extension is attached to every v2 402 the gem renders, echoed by paying clients, and indexed by facilitator catalogs (PayAI, Coinbase CDP Bazaar)
|
|
9
|
+
- **`X402::DiscoveryExtension.declare`** - Builds the `extensions.bazaar` wire shape, matching `@x402/extensions` `declareDiscoveryExtension` (query and body forms)
|
|
10
|
+
- **Coinbase CDP facilitator auth** - Requests to `api.cdp.coinbase.com` carry the required Bearer JWT (ES256 and Ed25519 keys, no new dependencies). Credentials via `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET` or `config.cdp_api_key_id` / `config.cdp_api_key_secret`
|
|
11
|
+
- **`FacilitatorClient#discovery_resources`** - Query a facilitator's discovery catalog
|
|
12
|
+
- **`x402_payment_header` / `x402_payment_attempted?`** - Controller helpers for the version-appropriate payment header, for conditional paywall flows and idempotency fingerprints
|
|
13
|
+
- `x402_discovery(description:)` sets the 402 `resource.description` facilitator catalogs display; `x402_paywall(extensions:)` attaches a prebuilt extensions hash directly
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- 402 responses set `Cache-Control: no-store`
|
|
17
|
+
- Facilitator request/response bodies and settlement diagnostics log at `debug`; settlement outcomes stay at `info`/`error`
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **v2 PaymentPayload dropped client-echoed `extensions`** - `to_h` hardcoded `extensions: {}`, so the discovery extension never reached the facilitator on verify/settle and routes could not be indexed. Client extensions are now forwarded untouched
|
|
21
|
+
- Settlement failures returning HTTP 400 from the facilitator no longer raise out of the settlement hook; they are logged and handled like other settlement errors
|
|
22
|
+
- `rails generate x402:install` - the install generator was not discoverable (it lived outside Rails' generator load path)
|
|
23
|
+
|
|
24
|
+
With no discovery declared and a non-CDP facilitator, the only wire change from 1.1.0 is the 402 `Cache-Control` header.
|
|
25
|
+
|
|
5
26
|
## [1.0.0] - 2026-01-07
|
|
6
27
|
|
|
7
28
|
### Added
|
data/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Supports 18 networks including Base, Polygon, Avalanche, Sei, Solana, and more.
|
|
|
19
19
|
- **Optimistic & non-optimistic** settlement modes
|
|
20
20
|
- **Automatic settlement** after successful responses
|
|
21
21
|
- **API paywall** with 402 payment-required responses
|
|
22
|
+
- **Bazaar discovery** to list your routes in facilitator catalogs (PayAI, Coinbase CDP)
|
|
23
|
+
- **Coinbase CDP facilitator** support with built-in auth
|
|
22
24
|
- **Rails 7.0+** compatible
|
|
23
25
|
|
|
24
26
|
## Example Video
|
|
@@ -43,12 +45,18 @@ bundle install
|
|
|
43
45
|
|
|
44
46
|
### 1. Configure the gem
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
Generate the initializer:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bin/rails generate x402:install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then edit `config/initializers/x402.rb`:
|
|
47
55
|
|
|
48
56
|
```ruby
|
|
49
57
|
X402.configure do |config|
|
|
50
58
|
config.wallet_address = ENV['X402_WALLET_ADDRESS'] # Your recipient wallet
|
|
51
|
-
config.facilitator = "https://x402.org/facilitator"
|
|
59
|
+
config.facilitator = "https://www.x402.org/facilitator"
|
|
52
60
|
config.chain = "base-sepolia" # or "base" for base mainnet
|
|
53
61
|
config.currency = "USDC"
|
|
54
62
|
config.optimistic = false # Forces to check for settlement before giving response.
|
|
@@ -75,6 +83,21 @@ end
|
|
|
75
83
|
|
|
76
84
|
That's it! Your endpoint now requires payment.
|
|
77
85
|
|
|
86
|
+
### 3. (Optional) Make it discoverable
|
|
87
|
+
|
|
88
|
+
Declare discovery metadata and agents can find your route in facilitator catalogs (see [Bazaar Discovery](#bazaar-discovery)):
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class ApiController < ApplicationController
|
|
92
|
+
x402_discovery only: :weather,
|
|
93
|
+
input: { "city" => "San Francisco" },
|
|
94
|
+
input_schema: { "properties" => { "city" => { "type" => "string" } } },
|
|
95
|
+
output: { example: { "temperature" => 72 } }
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Indexing happens when a client pays: the extension rides the 402, the paying client echoes it, and the facilitator catalogs the route on settle.
|
|
100
|
+
|
|
78
101
|
## Usage Patterns
|
|
79
102
|
|
|
80
103
|
### Direct Method Call
|
|
@@ -141,8 +164,8 @@ X402.configure do |config|
|
|
|
141
164
|
# Required: Your wallet address where payments will be received
|
|
142
165
|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
|
|
143
166
|
|
|
144
|
-
# Facilitator service URL (default: "https://x402.org/facilitator")
|
|
145
|
-
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
|
|
167
|
+
# Facilitator service URL (default: "https://www.x402.org/facilitator")
|
|
168
|
+
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://www.x402.org/facilitator")
|
|
146
169
|
|
|
147
170
|
# Blockchain network (default: "base-sepolia")
|
|
148
171
|
# Built-in: base, base-sepolia, polygon, polygon-amoy, avalanche, avalanche-fuji,
|
|
@@ -155,10 +178,10 @@ X402.configure do |config|
|
|
|
155
178
|
# Currently only USDC is supported
|
|
156
179
|
config.currency = ENV.fetch("X402_CURRENCY","USDC")
|
|
157
180
|
|
|
158
|
-
# Optimistic mode (default:
|
|
181
|
+
# Optimistic mode (default: false)
|
|
159
182
|
# true: Fast response, settle payment after response is sent
|
|
160
183
|
# false: Wait for blockchain settlement before sending response
|
|
161
|
-
config.optimistic = ENV.fetch("X402_OPTIMISTIC",false)
|
|
184
|
+
config.optimistic = ENV.fetch("X402_OPTIMISTIC", "false") == "true"
|
|
162
185
|
end
|
|
163
186
|
```
|
|
164
187
|
|
|
@@ -167,11 +190,13 @@ end
|
|
|
167
190
|
| Attribute | Required | Default | Description |
|
|
168
191
|
| ---------------- | -------- | -------------------------------- | --------------------------------------------------------------------------------- |
|
|
169
192
|
| `wallet_address` | **Yes** | - | Your Ethereum wallet address where payments will be received |
|
|
170
|
-
| `facilitator` | No | `"https://x402.org/facilitator"` | Facilitator service URL for payment verification and settlement
|
|
193
|
+
| `facilitator` | No | `"https://www.x402.org/facilitator"` | Facilitator service URL for payment verification and settlement |
|
|
171
194
|
| `chain` | No | `"base-sepolia"` | Blockchain network (see built-in list above) |
|
|
172
195
|
| `currency` | No | `"USDC"` | Payment token symbol (currently only USDC supported) |
|
|
173
|
-
| `optimistic` | No | `
|
|
196
|
+
| `optimistic` | No | `false` | `true`: respond before settlement; `false`: settle before responding |
|
|
174
197
|
| `version` | No | `2` | Protocol version (1 or 2). See Protocol Versions section |
|
|
198
|
+
| `cdp_api_key_id` | No | `ENV["CDP_API_KEY_ID"]` | Coinbase CDP API key id — only used when the facilitator is CDP |
|
|
199
|
+
| `cdp_api_key_secret` | No | `ENV["CDP_API_KEY_SECRET"]` | Coinbase CDP API key secret (ECDSA PEM or Ed25519 base64) |
|
|
175
200
|
|
|
176
201
|
### Custom Chains and Tokens
|
|
177
202
|
|
|
@@ -347,6 +372,157 @@ def legacy_v1
|
|
|
347
372
|
end
|
|
348
373
|
```
|
|
349
374
|
|
|
375
|
+
## Bazaar Discovery
|
|
376
|
+
|
|
377
|
+
Facilitators with a discovery layer (PayAI, Coinbase CDP Bazaar) index your route into their public catalog when a payment carries the x402 Bazaar discovery extension. Declare it per action and the gem attaches it to every v2 402 for that action; paying clients echo it into their payment payload, and the gem forwards the echo to the facilitator on verify/settle — that's what creates the catalog entry.
|
|
378
|
+
|
|
379
|
+
> **Note:** Parameterized routes (e.g. `/weather/:city`) are cataloged per concrete URL — the gem does not emit the optional `routeTemplate` field.
|
|
380
|
+
|
|
381
|
+
### GET route (query params)
|
|
382
|
+
|
|
383
|
+
Omit `body_type` for GET/HEAD/DELETE — `input` describes query params:
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
class WeatherController < ApplicationController
|
|
387
|
+
x402_discovery only: :show,
|
|
388
|
+
input: { "city" => "San Francisco", "units" => "celsius" },
|
|
389
|
+
input_schema: {
|
|
390
|
+
"type" => "object",
|
|
391
|
+
"properties" => {
|
|
392
|
+
"city" => { "type" => "string", "description" => "City name" },
|
|
393
|
+
"units" => { "type" => "string", "enum" => ["celsius", "fahrenheit"] },
|
|
394
|
+
},
|
|
395
|
+
"required" => ["city"],
|
|
396
|
+
},
|
|
397
|
+
output: { example: { "weather" => "foggy", "temperature" => 15 } }
|
|
398
|
+
|
|
399
|
+
def show
|
|
400
|
+
x402_paywall(amount: 0.001)
|
|
401
|
+
return if performed?
|
|
402
|
+
render json: { weather: "foggy", temperature: 15 }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### POST route (JSON body)
|
|
408
|
+
|
|
409
|
+
Pass `body_type: "json"` — `input` is now an example request body. Give every field a `description`: it's what agents read in the catalog to call you correctly.
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
class SearchController < ApplicationController
|
|
413
|
+
x402_discovery only: :create,
|
|
414
|
+
body_type: "json",
|
|
415
|
+
input: {
|
|
416
|
+
"query" => "solar panels",
|
|
417
|
+
"filters" => { "max_price" => 100 },
|
|
418
|
+
},
|
|
419
|
+
input_schema: {
|
|
420
|
+
"type" => "object",
|
|
421
|
+
"properties" => {
|
|
422
|
+
"query" => { "type" => "string", "description" => "Search terms" },
|
|
423
|
+
"filters" => {
|
|
424
|
+
"type" => "object",
|
|
425
|
+
"properties" => { "max_price" => { "type" => "number" } },
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
"required" => ["query"],
|
|
429
|
+
},
|
|
430
|
+
output: {
|
|
431
|
+
example: { "results" => [{ "title" => "…", "price" => 42 }] },
|
|
432
|
+
schema: { "properties" => { "results" => { "type" => "array" } } },
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
def create
|
|
436
|
+
x402_paywall(amount: 0.005)
|
|
437
|
+
return if performed?
|
|
438
|
+
render json: { results: search_results }
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Multiple routes in one controller
|
|
444
|
+
|
|
445
|
+
Each action gets its own declaration; undeclared actions emit no extension:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
class ReportsController < ApplicationController
|
|
449
|
+
x402_discovery only: :create, body_type: "json",
|
|
450
|
+
input: { "ticker" => "AAPL" },
|
|
451
|
+
input_schema: { "properties" => { "ticker" => { "type" => "string" } }, "required" => ["ticker"] },
|
|
452
|
+
output: { example: { "report_id" => "rep_123" } }
|
|
453
|
+
|
|
454
|
+
x402_discovery only: :summary,
|
|
455
|
+
input: { "report_id" => "rep_123" },
|
|
456
|
+
input_schema: { "properties" => { "report_id" => { "type" => "string" } }, "required" => ["report_id"] },
|
|
457
|
+
output: { example: { "summary" => "…" } }
|
|
458
|
+
end
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### Full control
|
|
462
|
+
|
|
463
|
+
Build the extension yourself (e.g. to share schemas with your validators), or attach per-call:
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
x402_discovery only: :create, extensions: X402::DiscoveryExtension.declare(
|
|
467
|
+
body_type: "json",
|
|
468
|
+
input: MyApi::EXAMPLE_BODY,
|
|
469
|
+
input_schema: MyApi::BODY_SCHEMA,
|
|
470
|
+
output: { example: MyApi::EXAMPLE_RESPONSE },
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# or, inside an action:
|
|
474
|
+
x402_paywall(amount: 0.005, extensions: my_extensions)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Indexing rules
|
|
478
|
+
|
|
479
|
+
- The example `input` **must validate against `input_schema`** — facilitators silently skip routes whose extension fails validation. Keep example and schema in sync.
|
|
480
|
+
- `method` is stamped from the actual request at render time — omit it; a declared value is overwritten.
|
|
481
|
+
- `description:` on `x402_discovery` sets the 402's `resource.description` — the text catalogs display for the route. A description alone just names the route; declaring input/output metadata is what makes it discoverable.
|
|
482
|
+
- Catalogs are **per-facilitator** — an entry appears only in the catalog of the facilitator that settled the payment. To appear in both PayAI and CDP, settle at least one payment through each.
|
|
483
|
+
- Indexing is a side-effect of a real payment; there is no registration API.
|
|
484
|
+
|
|
485
|
+
Verify a listing:
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
X402::FacilitatorClient.new.discovery_resources(type: "http")
|
|
489
|
+
# or against a specific facilitator:
|
|
490
|
+
X402::FacilitatorClient.new("https://facilitator.payai.network").discovery_resources
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Facilitators
|
|
494
|
+
|
|
495
|
+
Any x402 facilitator works via `X402_FACILITATOR_URL` / `config.facilitator`. Auth is applied automatically:
|
|
496
|
+
|
|
497
|
+
| Facilitator | URL | Auth |
|
|
498
|
+
| ----------- | --- | ---- |
|
|
499
|
+
| x402.org (default) | `https://www.x402.org/facilitator` | none |
|
|
500
|
+
| PayAI | `https://facilitator.payai.network` | none |
|
|
501
|
+
| Coinbase CDP | `https://api.cdp.coinbase.com/platform/v2/x402` | Bearer JWT, built in |
|
|
502
|
+
|
|
503
|
+
### Using Coinbase CDP
|
|
504
|
+
|
|
505
|
+
1. Create a project + API key at [cdp.coinbase.com](https://cdp.coinbase.com) (ECDSA and Ed25519 keys both work).
|
|
506
|
+
2. Set the env vars CDP's own SDKs use — the gem picks them up automatically:
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
|
|
510
|
+
CDP_API_KEY_ID=your-key-id
|
|
511
|
+
CDP_API_KEY_SECRET=your-key-secret
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Or configure explicitly:
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
X402.configure do |config|
|
|
518
|
+
config.facilitator = "https://api.cdp.coinbase.com/platform/v2/x402"
|
|
519
|
+
config.cdp_api_key_id = Rails.application.credentials.dig(:cdp, :api_key_id)
|
|
520
|
+
config.cdp_api_key_secret = Rails.application.credentials.dig(:cdp, :api_key_secret)
|
|
521
|
+
end
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Every verify/settle/supported/discovery request then carries a per-request, URI-bound Bearer JWT (2-minute expiry), matching `@coinbase/cdp-sdk`. Note: CDP's Bazaar discovery currently covers Base and Base Sepolia USDC.
|
|
525
|
+
|
|
350
526
|
## Environment Variables
|
|
351
527
|
|
|
352
528
|
Configure via environment variables:
|
|
@@ -356,11 +532,15 @@ Configure via environment variables:
|
|
|
356
532
|
X402_WALLET_ADDRESS=0xYourAddress
|
|
357
533
|
|
|
358
534
|
# Optional (with defaults)
|
|
359
|
-
X402_FACILITATOR_URL=https://x402.org/facilitator
|
|
535
|
+
X402_FACILITATOR_URL=https://www.x402.org/facilitator
|
|
360
536
|
X402_CHAIN=base-sepolia
|
|
361
537
|
X402_CURRENCY=USDC
|
|
362
538
|
X402_OPTIMISTIC=true # "true" or "false"
|
|
363
539
|
|
|
540
|
+
# Coinbase CDP facilitator auth (only needed when X402_FACILITATOR_URL is CDP)
|
|
541
|
+
CDP_API_KEY_ID=
|
|
542
|
+
CDP_API_KEY_SECRET=
|
|
543
|
+
|
|
364
544
|
# Solana fee payer overrides (required when using a non-default facilitator)
|
|
365
545
|
# The default fee payer is for the Coinbase facilitator (x402.org).
|
|
366
546
|
# Each facilitator manages its own fee payer — check your facilitator's /supported endpoint.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates X402 initializer for your application"
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template "x402_initializer.rb", "config/initializers/x402.rb"
|
|
12
|
+
|
|
13
|
+
puts ""
|
|
14
|
+
puts "✅ X402 initializer created at config/initializers/x402.rb"
|
|
15
|
+
puts ""
|
|
16
|
+
puts "Next steps:"
|
|
17
|
+
puts "1. Set your X402_WALLET_ADDRESS environment variable"
|
|
18
|
+
puts "2. Configure your preferred chain (base-sepolia for testing, base for production)"
|
|
19
|
+
puts "3. Add x402_paywall(amount: 0.001) to any controller action"
|
|
20
|
+
puts ""
|
|
21
|
+
puts "Example usage:"
|
|
22
|
+
puts " def show"
|
|
23
|
+
puts " x402_paywall(amount: 0.001) # $0.001 payment required"
|
|
24
|
+
puts " render json: @data"
|
|
25
|
+
puts " end"
|
|
26
|
+
puts ""
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -19,7 +19,16 @@ X402.configure do |config|
|
|
|
19
19
|
|
|
20
20
|
# Currency symbol (currently only USDC is supported)
|
|
21
21
|
config.currency = ENV.fetch("X402_CURRENCY", "USDC")
|
|
22
|
+
|
|
23
|
+
# Coinbase CDP facilitator (https://api.cdp.coinbase.com/platform/v2/x402)
|
|
24
|
+
# requires API credentials. These are read automatically from CDP_API_KEY_ID /
|
|
25
|
+
# CDP_API_KEY_SECRET, or set them explicitly:
|
|
26
|
+
# config.cdp_api_key_id = ENV.fetch("CDP_API_KEY_ID", nil)
|
|
27
|
+
# config.cdp_api_key_secret = ENV.fetch("CDP_API_KEY_SECRET", nil)
|
|
22
28
|
end
|
|
23
29
|
|
|
30
|
+
# To list paywalled routes in facilitator Bazaar catalogs, declare discovery
|
|
31
|
+
# metadata in the controller with `x402_discovery` — see the README.
|
|
32
|
+
|
|
24
33
|
# Validate configuration on initialization
|
|
25
34
|
X402.configuration.validate!
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module X402
|
|
7
|
+
# Bearer JWT auth for the Coinbase CDP facilitator, mirroring
|
|
8
|
+
# @coinbase/cdp-sdk generateJwt: per-request, URI-bound claims, signed with
|
|
9
|
+
# ES256 (PEM EC key) or EdDSA (base64 Ed25519 seed+public key).
|
|
10
|
+
#
|
|
11
|
+
# Applied automatically by FacilitatorClient when the configured facilitator
|
|
12
|
+
# host is api.cdp.coinbase.com. Credentials come from `config.cdp_api_key_id`
|
|
13
|
+
# and `config.cdp_api_key_secret` (or the CDP_API_KEY_ID / CDP_API_KEY_SECRET
|
|
14
|
+
# env vars CDP's own SDKs use). Other facilitators are untouched.
|
|
15
|
+
class CdpFacilitatorAuth
|
|
16
|
+
CDP_HOST = "api.cdp.coinbase.com"
|
|
17
|
+
EXPIRY_SECONDS = 120
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def headers_for(facilitator_url:, http_method:, endpoint:)
|
|
21
|
+
uri = URI(facilitator_url)
|
|
22
|
+
return {} unless uri.host == CDP_HOST
|
|
23
|
+
|
|
24
|
+
config = X402.configuration
|
|
25
|
+
key_id = config.cdp_api_key_id
|
|
26
|
+
secret = config.cdp_api_key_secret
|
|
27
|
+
if key_id.nil? || key_id.empty? || secret.nil? || secret.empty?
|
|
28
|
+
X402.logger.error(
|
|
29
|
+
"[x402] CDP facilitator requires cdp_api_key_id and cdp_api_key_secret " \
|
|
30
|
+
"(or CDP_API_KEY_ID / CDP_API_KEY_SECRET); sending the request unauthenticated",
|
|
31
|
+
)
|
|
32
|
+
return {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
jwt = bearer_jwt(
|
|
36
|
+
key_id: key_id,
|
|
37
|
+
secret: secret,
|
|
38
|
+
http_method: http_method,
|
|
39
|
+
request_host: uri.host,
|
|
40
|
+
request_path: "#{uri.path}/#{endpoint}",
|
|
41
|
+
)
|
|
42
|
+
jwt ? { "Authorization" => "Bearer #{jwt}" } : {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def bearer_jwt(key_id:, secret:, http_method:, request_host:, request_path:)
|
|
48
|
+
now = Time.now.to_i
|
|
49
|
+
claims = {
|
|
50
|
+
sub: key_id,
|
|
51
|
+
iss: "cdp",
|
|
52
|
+
uris: ["#{http_method} #{request_host}#{request_path}"],
|
|
53
|
+
iat: now,
|
|
54
|
+
nbf: now,
|
|
55
|
+
exp: now + EXPIRY_SECONDS,
|
|
56
|
+
}
|
|
57
|
+
header = { kid: key_id, typ: "JWT", nonce: SecureRandom.hex(16) }
|
|
58
|
+
|
|
59
|
+
if (key = ec_key(secret))
|
|
60
|
+
signed_jwt(claims, header.merge(alg: "ES256")) { |input| ecdsa_raw_signature(key, input) }
|
|
61
|
+
elsif (key = ed25519_key(secret))
|
|
62
|
+
signed_jwt(claims, header.merge(alg: "EdDSA")) { |input| key.sign(nil, input) }
|
|
63
|
+
else
|
|
64
|
+
X402.logger.error("[x402] CDP_API_KEY_SECRET is neither a PEM EC key nor a base64 Ed25519 key")
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def signed_jwt(claims, header)
|
|
70
|
+
signing_input = [encode_segment(header.to_json), encode_segment(claims.to_json)].join(".")
|
|
71
|
+
[signing_input, encode_segment(yield(signing_input))].join(".")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def encode_segment(bytes)
|
|
75
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ec_key(secret)
|
|
79
|
+
pem = secret.gsub("\\n", "\n")
|
|
80
|
+
return nil unless pem.include?("PRIVATE KEY")
|
|
81
|
+
|
|
82
|
+
key = OpenSSL::PKey.read(pem)
|
|
83
|
+
key.is_a?(OpenSSL::PKey::EC) ? key : nil
|
|
84
|
+
rescue OpenSSL::PKey::PKeyError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# CDP Ed25519 secrets are base64(seed(32) || public_key(32)); OpenSSL only
|
|
89
|
+
# imports the seed wrapped in PKCS#8 DER.
|
|
90
|
+
def ed25519_key(secret)
|
|
91
|
+
decoded = Base64.strict_decode64(secret)
|
|
92
|
+
return nil unless decoded.bytesize == 64
|
|
93
|
+
|
|
94
|
+
seed = decoded.byteslice(0, 32)
|
|
95
|
+
der = OpenSSL::ASN1::Sequence([
|
|
96
|
+
OpenSSL::ASN1::Integer(0),
|
|
97
|
+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId("ED25519")]),
|
|
98
|
+
OpenSSL::ASN1::OctetString(OpenSSL::ASN1::OctetString(seed).to_der),
|
|
99
|
+
]).to_der
|
|
100
|
+
OpenSSL::PKey.read(der)
|
|
101
|
+
rescue ArgumentError, OpenSSL::PKey::PKeyError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# JWS ES256 signatures are raw r||s (32 bytes each); OpenSSL emits DER.
|
|
106
|
+
def ecdsa_raw_signature(key, signing_input)
|
|
107
|
+
der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
|
|
108
|
+
r, s = OpenSSL::ASN1.decode(der).value.map(&:value)
|
|
109
|
+
[r, s].map { |bn| bn.to_s(2).rjust(32, "\x00".b) }.join
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/x402/chains.rb
CHANGED
|
@@ -133,6 +133,22 @@ module X402
|
|
|
133
133
|
currency: USDC_NAMED_USDC,
|
|
134
134
|
},
|
|
135
135
|
|
|
136
|
+
# --- XDC ---
|
|
137
|
+
"xdc" => {
|
|
138
|
+
chain_id: 50,
|
|
139
|
+
caip2: "eip155:50",
|
|
140
|
+
usdc_address: "0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1",
|
|
141
|
+
explorer_url: "https://xdcscan.com",
|
|
142
|
+
currency: USDC_NAMED_USDC_V2,
|
|
143
|
+
},
|
|
144
|
+
"xdc-testnet" => {
|
|
145
|
+
chain_id: 51,
|
|
146
|
+
caip2: "eip155:51",
|
|
147
|
+
usdc_address: "0xb5AB69F7bBada22B28e79C8FFAECe55eF1c771D4",
|
|
148
|
+
explorer_url: "https://testnet.xdcscan.com",
|
|
149
|
+
currency: USDC_NAMED_USDC_V2,
|
|
150
|
+
},
|
|
151
|
+
|
|
136
152
|
# --- Solana ---
|
|
137
153
|
"solana" => {
|
|
138
154
|
chain_id: 101,
|
data/lib/x402/configuration.rb
CHANGED
|
@@ -4,7 +4,7 @@ module X402
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :wallet_address, :facilitator, :chain, :currency, :optimistic,
|
|
6
6
|
:version, :fee_payer, :custom_chains, :custom_tokens,
|
|
7
|
-
:accepted_payments
|
|
7
|
+
:accepted_payments, :cdp_api_key_id, :cdp_api_key_secret
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@wallet_address = ENV.fetch("X402_WALLET_ADDRESS", nil)
|
|
@@ -17,6 +17,10 @@ module X402
|
|
|
17
17
|
@custom_chains = {}
|
|
18
18
|
@custom_tokens = {}
|
|
19
19
|
@accepted_payments = []
|
|
20
|
+
# Coinbase CDP facilitator credentials (same env names as CDP's own SDKs).
|
|
21
|
+
# Only used when the facilitator host is api.cdp.coinbase.com.
|
|
22
|
+
@cdp_api_key_id = ENV.fetch("CDP_API_KEY_ID", nil)
|
|
23
|
+
@cdp_api_key_secret = ENV.fetch("CDP_API_KEY_SECRET", nil)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def accept(chain:, currency: "USDC", wallet_address: nil)
|
|
@@ -93,6 +97,11 @@ module X402
|
|
|
93
97
|
def reset_configuration!
|
|
94
98
|
@configuration = Configuration.new
|
|
95
99
|
end
|
|
100
|
+
|
|
101
|
+
# ::-qualified: bare Rails here resolves to the gem's own X402::Rails.
|
|
102
|
+
def logger
|
|
103
|
+
::Rails.logger
|
|
104
|
+
end
|
|
96
105
|
end
|
|
97
106
|
|
|
98
107
|
class ConfigurationError < StandardError; end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/deep_dup"
|
|
4
|
+
|
|
5
|
+
module X402
|
|
6
|
+
# Builds the x402 Bazaar discovery extension for a route, matching the wire
|
|
7
|
+
# shape of the official @x402/extensions `declareDiscoveryExtension` helper.
|
|
8
|
+
#
|
|
9
|
+
# Facilitators index a resource only when the paying client echoes this
|
|
10
|
+
# extension back in its PaymentPayload and `info` validates against `schema`.
|
|
11
|
+
# A mismatch is skipped silently, so example inputs must satisfy the declared
|
|
12
|
+
# input schema.
|
|
13
|
+
#
|
|
14
|
+
# X402::DiscoveryExtension.declare(
|
|
15
|
+
# body_type: "json",
|
|
16
|
+
# input: { "city" => "San Francisco" },
|
|
17
|
+
# input_schema: {
|
|
18
|
+
# "type" => "object",
|
|
19
|
+
# "properties" => { "city" => { "type" => "string" } },
|
|
20
|
+
# "required" => ["city"],
|
|
21
|
+
# },
|
|
22
|
+
# output: { example: { "weather" => "foggy" } },
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# `body_type` selects the body form (POST/PUT/PATCH); without it the query
|
|
26
|
+
# form (GET/HEAD/DELETE) is used and `input` describes query params. The
|
|
27
|
+
# actual request method is stamped at render time via `with_method`.
|
|
28
|
+
class DiscoveryExtension
|
|
29
|
+
JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
30
|
+
QUERY_METHODS = %w[GET HEAD DELETE].freeze
|
|
31
|
+
BODY_METHODS = %w[POST PUT PATCH].freeze
|
|
32
|
+
BODY_TYPES = %w[json form-data text].freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def declare(method: nil, body_type: nil, input: {}, input_schema: { "properties" => {} },
|
|
36
|
+
output: nil)
|
|
37
|
+
info, schema =
|
|
38
|
+
if body_type
|
|
39
|
+
body_form(method: method, body_type: body_type, input: input, input_schema: input_schema)
|
|
40
|
+
else
|
|
41
|
+
query_form(method: method, input: input, input_schema: input_schema)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attach_output(info, schema, output)
|
|
45
|
+
{ "bazaar" => { "info" => info, "schema" => schema } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Stamps the actual HTTP method into the declaration at request time,
|
|
49
|
+
# overwriting any declared method — mirroring the reference server
|
|
50
|
+
# extension's enrichment, which always reports the live request verb.
|
|
51
|
+
def with_method(extension, http_method)
|
|
52
|
+
input = extension.dig("bazaar", "info", "input")
|
|
53
|
+
return extension if input.nil?
|
|
54
|
+
|
|
55
|
+
extension.deep_dup.tap do |enriched|
|
|
56
|
+
enriched["bazaar"]["info"]["input"]["method"] = http_method
|
|
57
|
+
method_schema = enriched.dig("bazaar", "schema", "properties", "input", "properties")
|
|
58
|
+
method_schema["method"] = { "type" => "string", "enum" => [http_method] } if method_schema
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def query_form(method:, input:, input_schema:)
|
|
65
|
+
info_input = { "type" => "http" }
|
|
66
|
+
info_input["method"] = method if method
|
|
67
|
+
info_input["queryParams"] = input if input
|
|
68
|
+
|
|
69
|
+
properties = {
|
|
70
|
+
"type" => { "type" => "string", "const" => "http" },
|
|
71
|
+
"method" => { "type" => "string", "enum" => QUERY_METHODS },
|
|
72
|
+
}
|
|
73
|
+
properties["queryParams"] = { "type" => "object" }.merge(input_schema) if input_schema
|
|
74
|
+
|
|
75
|
+
[{ "input" => info_input }, schema_for(properties, required: %w[type method])]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def body_form(method:, body_type:, input:, input_schema:)
|
|
79
|
+
info_input = { "type" => "http" }
|
|
80
|
+
info_input["method"] = method if method
|
|
81
|
+
info_input["bodyType"] = body_type
|
|
82
|
+
info_input["body"] = input || {}
|
|
83
|
+
|
|
84
|
+
properties = {
|
|
85
|
+
"type" => { "type" => "string", "const" => "http" },
|
|
86
|
+
"method" => { "type" => "string", "enum" => BODY_METHODS },
|
|
87
|
+
"bodyType" => { "type" => "string", "enum" => BODY_TYPES },
|
|
88
|
+
"body" => input_schema,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
[{ "input" => info_input }, schema_for(properties, required: %w[type method bodyType body])]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def schema_for(input_properties, required:)
|
|
95
|
+
{
|
|
96
|
+
"$schema" => JSON_SCHEMA_DIALECT,
|
|
97
|
+
"type" => "object",
|
|
98
|
+
"properties" => {
|
|
99
|
+
"input" => {
|
|
100
|
+
"type" => "object",
|
|
101
|
+
"properties" => input_properties,
|
|
102
|
+
"required" => required,
|
|
103
|
+
"additionalProperties" => false,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"required" => ["input"],
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def attach_output(info, schema, output)
|
|
111
|
+
example = output && (output[:example] || output["example"])
|
|
112
|
+
return unless example
|
|
113
|
+
|
|
114
|
+
output_schema = output[:schema] || output["schema"]
|
|
115
|
+
info["output"] = { "type" => "json", "example" => example }
|
|
116
|
+
schema["properties"]["output"] = {
|
|
117
|
+
"type" => "object",
|
|
118
|
+
"properties" => {
|
|
119
|
+
"type" => { "type" => "string" },
|
|
120
|
+
"example" => { "type" => "object" }.merge(output_schema.is_a?(Hash) ? output_schema : {}),
|
|
121
|
+
},
|
|
122
|
+
"required" => ["type"],
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -12,69 +12,78 @@ module X402
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def verify(payment_payload, payment_requirements)
|
|
15
|
-
|
|
16
|
-
x402Version: payment_payload.x402_version,
|
|
17
|
-
paymentPayload: payment_payload.to_h,
|
|
18
|
-
paymentRequirements: payment_requirements
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
::Rails.logger.info("=== X402 Verify Request ===")
|
|
22
|
-
::Rails.logger.info("URL: #{facilitator_url}/verify")
|
|
23
|
-
::Rails.logger.info("Request body: #{request_body.to_json}")
|
|
24
|
-
|
|
25
|
-
response = connection.post("verify") do |req|
|
|
26
|
-
req.headers["Content-Type"] = "application/json"
|
|
27
|
-
req.body = request_body.to_json
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
::Rails.logger.info("Response status: #{response.status}")
|
|
31
|
-
::Rails.logger.info("Response body: #{response.body}")
|
|
32
|
-
|
|
15
|
+
response = post_payment("verify", payment_payload, payment_requirements)
|
|
33
16
|
handle_response(response, "verify")
|
|
34
17
|
rescue Faraday::Error => e
|
|
35
18
|
raise FacilitatorError, "Failed to verify payment: #{e.message}"
|
|
36
19
|
end
|
|
37
20
|
|
|
38
21
|
def settle(payment_payload, payment_requirements)
|
|
22
|
+
response = post_payment("settle", payment_payload, payment_requirements)
|
|
23
|
+
SettlementResponse.new(handle_response(response, "settle"))
|
|
24
|
+
rescue Faraday::Error => e
|
|
25
|
+
raise FacilitatorError, "Failed to settle payment: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def supported_networks
|
|
29
|
+
get_json("supported", action: "supported")
|
|
30
|
+
rescue Faraday::Error => e
|
|
31
|
+
raise FacilitatorError, "Failed to fetch supported networks: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Lists the facilitator's Bazaar discovery catalog (PayAI, CDP, and any
|
|
35
|
+
# other facilitator implementing the x402 discovery spec expose this).
|
|
36
|
+
# Params pass through as query string, e.g. type: "http", limit: 50.
|
|
37
|
+
def discovery_resources(params = {})
|
|
38
|
+
get_json("discovery/resources", action: "discovery", params: params)
|
|
39
|
+
rescue Faraday::Error => e
|
|
40
|
+
raise FacilitatorError, "Failed to fetch discovery resources: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def post_payment(endpoint, payment_payload, payment_requirements)
|
|
39
46
|
request_body = {
|
|
40
47
|
x402Version: payment_payload.x402_version,
|
|
41
48
|
paymentPayload: payment_payload.to_h,
|
|
42
49
|
paymentRequirements: payment_requirements
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
::Rails.logger.info("URL: #{facilitator_url}/settle")
|
|
47
|
-
::Rails.logger.info("Request body: #{request_body.to_json}")
|
|
52
|
+
X402.logger.debug { "[x402] POST #{facilitator_url}/#{endpoint} #{request_body.to_json}" }
|
|
48
53
|
|
|
49
|
-
response = connection.post(
|
|
54
|
+
response = connection.post(endpoint) do |req|
|
|
50
55
|
req.headers["Content-Type"] = "application/json"
|
|
56
|
+
req.headers.update(auth_headers("POST", endpoint))
|
|
51
57
|
req.body = request_body.to_json
|
|
52
58
|
end
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
settlement_data = handle_response(response, "settle")
|
|
58
|
-
SettlementResponse.new(settlement_data)
|
|
59
|
-
rescue Faraday::Error => e
|
|
60
|
-
raise FacilitatorError, "Failed to settle payment: #{e.message}"
|
|
60
|
+
X402.logger.debug { "[x402] #{endpoint} response #{response.status}: #{response.body}" }
|
|
61
|
+
response
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
def
|
|
64
|
-
response = connection.get(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
def get_json(endpoint, action:, params: {})
|
|
65
|
+
response = connection.get(endpoint) do |req|
|
|
66
|
+
req.params.update(params)
|
|
67
|
+
req.headers.update(auth_headers("GET", endpoint))
|
|
68
|
+
end
|
|
69
|
+
handle_response(response, action)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
private
|
|
71
|
-
|
|
72
72
|
def connection
|
|
73
73
|
@connection ||= Faraday.new(url: facilitator_url) do |faraday|
|
|
74
74
|
faraday.adapter Faraday.default_adapter
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# CDP requires a Bearer JWT on every request; all other facilitators get no auth.
|
|
79
|
+
def auth_headers(http_method, endpoint)
|
|
80
|
+
CdpFacilitatorAuth.headers_for(
|
|
81
|
+
facilitator_url: facilitator_url,
|
|
82
|
+
http_method: http_method,
|
|
83
|
+
endpoint: endpoint,
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
78
87
|
def handle_response(response, action)
|
|
79
88
|
case response.status
|
|
80
89
|
when 200..299
|
data/lib/x402/payment_payload.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module X402
|
|
4
4
|
class PaymentPayload
|
|
5
|
-
attr_accessor :x402_version, :scheme, :network, :payload, :accepted, :resource_info
|
|
5
|
+
attr_accessor :x402_version, :scheme, :network, :payload, :accepted, :resource_info, :extensions
|
|
6
6
|
|
|
7
7
|
def initialize(attributes = {})
|
|
8
8
|
attrs = attributes.with_indifferent_access
|
|
@@ -10,6 +10,7 @@ module X402
|
|
|
10
10
|
@x402_version = (attrs[:x402Version] || attrs[:x402_version] || 1).to_i
|
|
11
11
|
@accepted = attrs[:accepted]
|
|
12
12
|
@resource_info = attrs[:resource]
|
|
13
|
+
@extensions = attrs[:extensions]
|
|
13
14
|
|
|
14
15
|
if @accepted
|
|
15
16
|
@scheme = @accepted.with_indifferent_access[:scheme]
|
|
@@ -95,7 +96,8 @@ module X402
|
|
|
95
96
|
x402Version: x402_version,
|
|
96
97
|
accepted: format_accepted_for_version(version_strategy),
|
|
97
98
|
payload: payload,
|
|
98
|
-
extensions
|
|
99
|
+
# Client-echoed extensions (e.g. bazaar discovery) must survive to verify/settle
|
|
100
|
+
extensions: extensions.presence || {}
|
|
99
101
|
}
|
|
100
102
|
base[:resource] = resource_info if resource_info
|
|
101
103
|
base
|
|
@@ -7,6 +7,32 @@ module X402
|
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
9
|
after_action :settle_x402_payment_if_needed
|
|
10
|
+
class_attribute :x402_discovery_declarations, instance_writer: false, default: {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
# Declares Bazaar discovery metadata for paywalled actions so 402
|
|
15
|
+
# responses advertise the route to facilitator discovery catalogs.
|
|
16
|
+
#
|
|
17
|
+
# x402_discovery only: :create,
|
|
18
|
+
# body_type: "json",
|
|
19
|
+
# input: { "city" => "SF" },
|
|
20
|
+
# input_schema: { "properties" => { "city" => { "type" => "string" } } },
|
|
21
|
+
# output: { example: { "weather" => "foggy" } }
|
|
22
|
+
#
|
|
23
|
+
# Accepts either DiscoveryExtension.declare kwargs (as above) or a
|
|
24
|
+
# prebuilt hash via `extensions:`, plus `description:` for the 402's
|
|
25
|
+
# resource.description — the text facilitator catalogs display for the
|
|
26
|
+
# route. A description alone names the route without making it
|
|
27
|
+
# discoverable. Without `only:`, applies to every paywalled action in
|
|
28
|
+
# the controller. `method` is stamped from the actual request at render
|
|
29
|
+
# time.
|
|
30
|
+
def x402_discovery(only: nil, **config)
|
|
31
|
+
actions = only.nil? ? [:__all__] : Array(only).map(&:to_sym)
|
|
32
|
+
self.x402_discovery_declarations = x402_discovery_declarations.merge(
|
|
33
|
+
actions.to_h { |action| [action, config] }
|
|
34
|
+
)
|
|
35
|
+
end
|
|
10
36
|
end
|
|
11
37
|
|
|
12
38
|
def x402_paywall(options = {})
|
|
@@ -17,6 +43,7 @@ module X402
|
|
|
17
43
|
wallet_address = options[:wallet_address]
|
|
18
44
|
fee_payer = options[:fee_payer]
|
|
19
45
|
accepts = options[:accepts]
|
|
46
|
+
@x402_paywall_extensions = options[:extensions]
|
|
20
47
|
|
|
21
48
|
begin
|
|
22
49
|
version_strategy = X402::Versions.for(protocol_version)
|
|
@@ -49,6 +76,20 @@ module X402
|
|
|
49
76
|
)
|
|
50
77
|
end
|
|
51
78
|
|
|
79
|
+
# The raw payment header, or nil when absent (or when the version is
|
|
80
|
+
# invalid). Pass version: on endpoints that override it via
|
|
81
|
+
# x402_paywall(version:).
|
|
82
|
+
def x402_payment_header(version: nil)
|
|
83
|
+
version_strategy = X402::Versions.for(version || X402.configuration.version)
|
|
84
|
+
request.headers[version_strategy.payment_header_name].presence
|
|
85
|
+
rescue X402::ConfigurationError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def x402_payment_attempted?(version: nil)
|
|
90
|
+
x402_payment_header(version: version).present?
|
|
91
|
+
end
|
|
92
|
+
|
|
52
93
|
private
|
|
53
94
|
|
|
54
95
|
def generate_payment_required_response(amount, error_message = nil,
|
|
@@ -59,7 +100,7 @@ module X402
|
|
|
59
100
|
requirement_response = X402::RequirementGenerator.generate(
|
|
60
101
|
amount: amount,
|
|
61
102
|
resource: request.original_url,
|
|
62
|
-
description: "Payment required for #{request.path}",
|
|
103
|
+
description: x402_declared_description || "Payment required for #{request.path}",
|
|
63
104
|
chain: chain,
|
|
64
105
|
currency: currency,
|
|
65
106
|
version: protocol_version,
|
|
@@ -90,13 +131,54 @@ module X402
|
|
|
90
131
|
end
|
|
91
132
|
|
|
92
133
|
def render_402_response(requirement_response, version_strategy)
|
|
134
|
+
# The version strategy's output declares whether this protocol version
|
|
135
|
+
# carries extensions (v2 emits the key, v1 does not).
|
|
136
|
+
discovery = x402_resolved_discovery_extensions
|
|
137
|
+
if discovery.present? && requirement_response.key?(:extensions)
|
|
138
|
+
requirement_response[:extensions] = discovery
|
|
139
|
+
end
|
|
140
|
+
|
|
93
141
|
if version_strategy.requirement_header_name
|
|
94
142
|
response.headers[version_strategy.requirement_header_name] =
|
|
95
143
|
Base64.strict_encode64(requirement_response.to_json)
|
|
96
144
|
end
|
|
145
|
+
# payment challenges are per-request and must not be cached
|
|
146
|
+
response.headers["Cache-Control"] = "no-store"
|
|
97
147
|
render json: requirement_response, status: :payment_required
|
|
98
148
|
end
|
|
99
149
|
|
|
150
|
+
# Explicit x402_paywall(extensions:) wins over the class-level
|
|
151
|
+
# x402_discovery declaration for the current action. Either way the
|
|
152
|
+
# actual request method is stamped in, mirroring the reference server
|
|
153
|
+
# extension's request-time enrichment.
|
|
154
|
+
def x402_resolved_discovery_extensions
|
|
155
|
+
extension = @x402_paywall_extensions.presence || x402_declared_extension
|
|
156
|
+
return nil if extension.nil?
|
|
157
|
+
|
|
158
|
+
X402::DiscoveryExtension.with_method(extension, request.method)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A description-only declaration just names the route; the extension is
|
|
162
|
+
# built only when I/O metadata (or a prebuilt hash) is declared.
|
|
163
|
+
def x402_declared_extension
|
|
164
|
+
config = x402_discovery_config
|
|
165
|
+
return nil if config.blank?
|
|
166
|
+
return config[:extensions] if config[:extensions]
|
|
167
|
+
|
|
168
|
+
declare_kwargs = config.except(:description)
|
|
169
|
+
declare_kwargs.presence && X402::DiscoveryExtension.declare(**declare_kwargs)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The catalog display text declared via x402_discovery(description:) —
|
|
173
|
+
# facilitators surface it from the 402's resource.description.
|
|
174
|
+
def x402_declared_description
|
|
175
|
+
x402_discovery_config&.dig(:description)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def x402_discovery_config
|
|
179
|
+
x402_discovery_declarations[action_name&.to_sym] || x402_discovery_declarations[:__all__]
|
|
180
|
+
end
|
|
181
|
+
|
|
100
182
|
def render_configuration_error(message)
|
|
101
183
|
# Use V1 as a safe fallback when version is invalid to avoid recursive errors
|
|
102
184
|
fallback_strategy = X402::Versions::V1.new
|
|
@@ -229,7 +311,6 @@ module X402
|
|
|
229
311
|
payment_info = request.env["x402.payment"]
|
|
230
312
|
return unless payment_info
|
|
231
313
|
|
|
232
|
-
::Rails.logger.info("=== X402 Non-Optimistic Settlement (before response) ===")
|
|
233
314
|
settlement_result = perform_settlement(payment_info)
|
|
234
315
|
|
|
235
316
|
request.env["x402.settlement_result"] = settlement_result
|
|
@@ -238,12 +319,11 @@ module X402
|
|
|
238
319
|
|
|
239
320
|
def perform_settlement(payment_info)
|
|
240
321
|
begin
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
::Rails.logger.info("Requirement hash: #{payment_info[:requirement].to_h.inspect}")
|
|
322
|
+
X402.logger.debug do
|
|
323
|
+
"[x402] settling payment optimistic=#{X402.configuration.optimistic} " \
|
|
324
|
+
"payload=#{payment_info[:payload].inspect} " \
|
|
325
|
+
"requirement=#{payment_info[:requirement].to_h.inspect}"
|
|
326
|
+
end
|
|
247
327
|
|
|
248
328
|
protocol_version = payment_info[:version] || X402.configuration.version
|
|
249
329
|
version_strategy = X402::Versions.for(protocol_version)
|
|
@@ -256,15 +336,15 @@ module X402
|
|
|
256
336
|
|
|
257
337
|
if settlement_result.success?
|
|
258
338
|
response.headers[version_strategy.response_header_name] = settlement_result.to_base64
|
|
259
|
-
|
|
339
|
+
X402.logger.info("x402 settlement successful: #{settlement_result.transaction}")
|
|
260
340
|
else
|
|
261
|
-
|
|
341
|
+
X402.logger.error("x402 settlement failed: #{settlement_result.error_reason}")
|
|
262
342
|
end
|
|
263
343
|
|
|
264
344
|
settlement_result
|
|
265
|
-
rescue X402::FacilitatorError => e
|
|
266
|
-
|
|
267
|
-
|
|
345
|
+
rescue X402::FacilitatorError, X402::InvalidPaymentError => e
|
|
346
|
+
X402.logger.error("x402 settlement error: #{e.message}")
|
|
347
|
+
X402::SettlementResponse.new("success" => false, "errorReason" => e.message)
|
|
268
348
|
end
|
|
269
349
|
end
|
|
270
350
|
|
data/lib/x402/rails/version.rb
CHANGED
data/lib/x402/rails.rb
CHANGED
|
@@ -12,7 +12,9 @@ require_relative "../x402/chains"
|
|
|
12
12
|
require_relative "../x402/versions"
|
|
13
13
|
require_relative "../x402/payment_requirement"
|
|
14
14
|
require_relative "../x402/payment_payload"
|
|
15
|
+
require_relative "../x402/discovery_extension"
|
|
15
16
|
require_relative "../x402/settlement_response"
|
|
17
|
+
require_relative "../x402/cdp_facilitator_auth"
|
|
16
18
|
require_relative "../x402/facilitator_client"
|
|
17
19
|
require_relative "../x402/payment_validator"
|
|
18
20
|
require_relative "../x402/requirement_generator"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: x402-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- QuickNode
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -52,6 +52,20 @@ dependencies:
|
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '0.3'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: jwt
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.0'
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
70
|
name: rake
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -137,16 +151,18 @@ files:
|
|
|
137
151
|
- README.md
|
|
138
152
|
- Rakefile
|
|
139
153
|
- coverage/coverage.svg
|
|
154
|
+
- lib/generators/x402/install/install_generator.rb
|
|
155
|
+
- lib/generators/x402/install/templates/x402_initializer.rb
|
|
156
|
+
- lib/x402/cdp_facilitator_auth.rb
|
|
140
157
|
- lib/x402/chains.rb
|
|
141
158
|
- lib/x402/configuration.rb
|
|
159
|
+
- lib/x402/discovery_extension.rb
|
|
142
160
|
- lib/x402/facilitator_client.rb
|
|
143
161
|
- lib/x402/payment_payload.rb
|
|
144
162
|
- lib/x402/payment_requirement.rb
|
|
145
163
|
- lib/x402/payment_validator.rb
|
|
146
164
|
- lib/x402/rails.rb
|
|
147
165
|
- lib/x402/rails/controller_extensions.rb
|
|
148
|
-
- lib/x402/rails/generators/install_generator.rb
|
|
149
|
-
- lib/x402/rails/generators/templates/x402_initializer.rb
|
|
150
166
|
- lib/x402/rails/railtie.rb
|
|
151
167
|
- lib/x402/rails/version.rb
|
|
152
168
|
- lib/x402/requirement_generator.rb
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module X402
|
|
4
|
-
module Rails
|
|
5
|
-
module Generators
|
|
6
|
-
class InstallGenerator < ::Rails::Generators::Base
|
|
7
|
-
source_root File.expand_path("templates", __dir__)
|
|
8
|
-
|
|
9
|
-
desc "Creates X402 initializer for your application"
|
|
10
|
-
|
|
11
|
-
def copy_initializer
|
|
12
|
-
template "x402_initializer.rb", "config/initializers/x402.rb"
|
|
13
|
-
|
|
14
|
-
puts ""
|
|
15
|
-
puts "✅ X402 initializer created at config/initializers/x402.rb"
|
|
16
|
-
puts ""
|
|
17
|
-
puts "Next steps:"
|
|
18
|
-
puts "1. Set your X402_WALLET_ADDRESS environment variable"
|
|
19
|
-
puts "2. Configure your preferred chain (base-sepolia for testing, base for production)"
|
|
20
|
-
puts "3. Add x402_paywall(amount: 0.001) to any controller action"
|
|
21
|
-
puts ""
|
|
22
|
-
puts "Example usage:"
|
|
23
|
-
puts " def show"
|
|
24
|
-
puts " x402_paywall(amount: 0.001) # $0.001 payment required"
|
|
25
|
-
puts " render json: @data"
|
|
26
|
-
puts " end"
|
|
27
|
-
puts ""
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|