x402-rails 1.0.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 +229 -25
- 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 +156 -67
- data/lib/x402/configuration.rb +11 -2
- 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 +25 -9
- 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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Accept instant blockchain micropayments in your Rails applications using the [x402 payment protocol](https://www.x402.org/).
|
|
10
10
|
|
|
11
|
-
Supports Base,
|
|
11
|
+
Supports 18 networks including Base, Polygon, Avalanche, Sei, Solana, and more.
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
@@ -18,7 +18,9 @@ Supports Base, avalanche, and other blockchain networks.
|
|
|
18
18
|
- **$0.001 minimum** payment amounts
|
|
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,21 +164,24 @@ 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,
|
|
172
|
+
# sei, sei-testnet, iotex, peaq, xlayer, xlayer-testnet,
|
|
173
|
+
# skale-base, skale-base-sepolia, kiteai, kiteai-testnet,
|
|
174
|
+
# solana, solana-devnet
|
|
149
175
|
config.chain = ENV.fetch("X402_CHAIN", "base-sepolia")
|
|
150
176
|
|
|
151
177
|
# Payment token (default: "USDC")
|
|
152
178
|
# Currently only USDC is supported
|
|
153
179
|
config.currency = ENV.fetch("X402_CURRENCY","USDC")
|
|
154
180
|
|
|
155
|
-
# Optimistic mode (default:
|
|
181
|
+
# Optimistic mode (default: false)
|
|
156
182
|
# true: Fast response, settle payment after response is sent
|
|
157
183
|
# false: Wait for blockchain settlement before sending response
|
|
158
|
-
config.optimistic = ENV.fetch("X402_OPTIMISTIC",false)
|
|
184
|
+
config.optimistic = ENV.fetch("X402_OPTIMISTIC", "false") == "true"
|
|
159
185
|
end
|
|
160
186
|
```
|
|
161
187
|
|
|
@@ -164,11 +190,13 @@ end
|
|
|
164
190
|
| Attribute | Required | Default | Description |
|
|
165
191
|
| ---------------- | -------- | -------------------------------- | --------------------------------------------------------------------------------- |
|
|
166
192
|
| `wallet_address` | **Yes** | - | Your Ethereum wallet address where payments will be received |
|
|
167
|
-
| `facilitator` | No | `"https://x402.org/facilitator"` | Facilitator service URL for payment verification and settlement
|
|
168
|
-
| `chain` | No | `"base-sepolia"` | Blockchain network
|
|
193
|
+
| `facilitator` | No | `"https://www.x402.org/facilitator"` | Facilitator service URL for payment verification and settlement |
|
|
194
|
+
| `chain` | No | `"base-sepolia"` | Blockchain network (see built-in list above) |
|
|
169
195
|
| `currency` | No | `"USDC"` | Payment token symbol (currently only USDC supported) |
|
|
170
|
-
| `optimistic` | No | `
|
|
196
|
+
| `optimistic` | No | `false` | `true`: respond before settlement; `false`: settle before responding |
|
|
171
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) |
|
|
172
200
|
|
|
173
201
|
### Custom Chains and Tokens
|
|
174
202
|
|
|
@@ -176,30 +204,30 @@ You can register custom EVM chains and tokens beyond the built-in options.
|
|
|
176
204
|
|
|
177
205
|
#### Register a Custom Chain
|
|
178
206
|
|
|
179
|
-
Add support for any EVM-compatible chain:
|
|
207
|
+
Add support for any EVM-compatible chain beyond the 18 built-in networks:
|
|
180
208
|
|
|
181
209
|
```ruby
|
|
182
210
|
X402.configure do |config|
|
|
183
211
|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
|
|
184
212
|
|
|
185
|
-
# Register
|
|
213
|
+
# Register Arbitrum (not built-in)
|
|
186
214
|
config.register_chain(
|
|
187
|
-
name: "
|
|
188
|
-
chain_id:
|
|
215
|
+
name: "arbitrum",
|
|
216
|
+
chain_id: 42161,
|
|
189
217
|
standard: "eip155"
|
|
190
218
|
)
|
|
191
219
|
|
|
192
220
|
# Register the token for that chain
|
|
193
221
|
config.register_token(
|
|
194
|
-
chain: "
|
|
222
|
+
chain: "arbitrum",
|
|
195
223
|
symbol: "USDC",
|
|
196
|
-
address: "
|
|
224
|
+
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
197
225
|
decimals: 6,
|
|
198
226
|
name: "USD Coin",
|
|
199
227
|
version: "2"
|
|
200
228
|
)
|
|
201
229
|
|
|
202
|
-
config.chain = "
|
|
230
|
+
config.chain = "arbitrum"
|
|
203
231
|
config.currency = "USDC"
|
|
204
232
|
end
|
|
205
233
|
```
|
|
@@ -250,20 +278,21 @@ Allow clients to pay on any of several supported chains by using `config.accept(
|
|
|
250
278
|
X402.configure do |config|
|
|
251
279
|
config.wallet_address = ENV['X402_WALLET_ADDRESS']
|
|
252
280
|
|
|
253
|
-
# Register a custom chain
|
|
254
|
-
config.register_chain(name: "
|
|
281
|
+
# Register a custom chain not included in the built-in list
|
|
282
|
+
config.register_chain(name: "arbitrum", chain_id: 42161, standard: "eip155")
|
|
255
283
|
config.register_token(
|
|
256
|
-
chain: "
|
|
284
|
+
chain: "arbitrum",
|
|
257
285
|
symbol: "USDC",
|
|
258
|
-
address: "
|
|
286
|
+
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
259
287
|
decimals: 6,
|
|
260
288
|
name: "USD Coin",
|
|
261
289
|
version: "2"
|
|
262
290
|
)
|
|
263
291
|
|
|
264
|
-
# Accept payments on multiple chains
|
|
292
|
+
# Accept payments on multiple chains (built-in + custom)
|
|
265
293
|
config.accept(chain: "base-sepolia", currency: "USDC")
|
|
266
294
|
config.accept(chain: "polygon-amoy", currency: "USDC")
|
|
295
|
+
config.accept(chain: "arbitrum", currency: "USDC")
|
|
267
296
|
end
|
|
268
297
|
```
|
|
269
298
|
|
|
@@ -343,6 +372,157 @@ def legacy_v1
|
|
|
343
372
|
end
|
|
344
373
|
```
|
|
345
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
|
+
|
|
346
526
|
## Environment Variables
|
|
347
527
|
|
|
348
528
|
Configure via environment variables:
|
|
@@ -352,12 +532,34 @@ Configure via environment variables:
|
|
|
352
532
|
X402_WALLET_ADDRESS=0xYourAddress
|
|
353
533
|
|
|
354
534
|
# Optional (with defaults)
|
|
355
|
-
X402_FACILITATOR_URL=https://x402.org/facilitator
|
|
535
|
+
X402_FACILITATOR_URL=https://www.x402.org/facilitator
|
|
356
536
|
X402_CHAIN=base-sepolia
|
|
357
537
|
X402_CURRENCY=USDC
|
|
358
538
|
X402_OPTIMISTIC=true # "true" or "false"
|
|
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
|
+
|
|
544
|
+
# Solana fee payer overrides (required when using a non-default facilitator)
|
|
545
|
+
# The default fee payer is for the Coinbase facilitator (x402.org).
|
|
546
|
+
# Each facilitator manages its own fee payer — check your facilitator's /supported endpoint.
|
|
547
|
+
X402_FEE_PAYER= # Global override for all Solana chains
|
|
548
|
+
X402_SOLANA_FEE_PAYER= # Solana mainnet override
|
|
549
|
+
X402_SOLANA_DEVNET_FEE_PAYER= # Solana devnet override
|
|
550
|
+
|
|
551
|
+
# Example: PayAI facilitator (https://facilitator.payai.network)
|
|
552
|
+
# X402_FACILITATOR_URL=https://facilitator.payai.network
|
|
553
|
+
# X402_SOLANA_FEE_PAYER=2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4
|
|
554
|
+
# X402_SOLANA_DEVNET_FEE_PAYER=2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4
|
|
359
555
|
```
|
|
360
556
|
|
|
557
|
+
Fee payer lookup priority:
|
|
558
|
+
1. `config.fee_payer` (programmatic, global)
|
|
559
|
+
2. Per-chain ENV variable (e.g., `X402_SOLANA_DEVNET_FEE_PAYER`)
|
|
560
|
+
3. `X402_FEE_PAYER` (ENV, global)
|
|
561
|
+
4. Built-in default from chain config
|
|
562
|
+
|
|
361
563
|
## Examples
|
|
362
564
|
|
|
363
565
|
### Weather API
|
|
@@ -419,6 +621,8 @@ The gem raises these errors:
|
|
|
419
621
|
- [x402 Protocol Docs](https://docs.cdp.coinbase.com/x402)
|
|
420
622
|
- [GitHub Repository](https://github.com/coinbase/x402)
|
|
421
623
|
- [Facilitator API](https://x402.org/facilitator)
|
|
624
|
+
- [Step-by-Step Rails Integration Guide](https://www.quicknode.com/guides/infrastructure/x402-payment-integration-with-rails)
|
|
625
|
+
|
|
422
626
|
|
|
423
627
|
## License
|
|
424
628
|
|
|
@@ -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
|