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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b70874283a564f69295e16df7dcb11d4764480c62151f130335ee3d116d62f5
4
- data.tar.gz: e4462c35ac33efc1993f166fa407b1e06f1b1fa35e3ba6547afce9031c07d3c4
3
+ metadata.gz: 5ec0c7a3137f3d98d641403e48438dc35677587631197581e3574804df7f42fa
4
+ data.tar.gz: f1d3d3ed62e675ad0c818e0202066c95cdddbd2c13ce6be3cec9ef04c1a47e94
5
5
  SHA512:
6
- metadata.gz: fb7d7df8bda864f433e062f41cc1e956c6a979446e02bd3bb88ef5bccc2ac42e06a2933a5cafe7b7475b53665d360b5bc034b80e4c9c9ff10b668df1f61a106d
7
- data.tar.gz: ea4523288ae8c5242fb03c90f1bc83f41779d46c170354cf2a3ccfdbaaf34da3f6656a03ab4b47d01f0d11e01e4adf1da4a52b405ddf25aa12f91270f11a6eaf
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, avalanche, and other blockchain networks.
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
- - **Browser paywall** and API support
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
- Create `config/initializers/x402.rb`:
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
- # Options: "base-sepolia", "base", "avalanche-fuji", "avalanche"
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: true)
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 to use (`base-sepolia`, `base`, `avalanche-fuji`, `avalanche`) |
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 | `true` | Settlement mode (see Optimistic vs Non-Optimistic Mode below) |
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 Polygon mainnet
213
+ # Register Arbitrum (not built-in)
186
214
  config.register_chain(
187
- name: "polygon",
188
- chain_id: 137,
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: "polygon",
222
+ chain: "arbitrum",
195
223
  symbol: "USDC",
196
- address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
224
+ address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
197
225
  decimals: 6,
198
226
  name: "USD Coin",
199
227
  version: "2"
200
228
  )
201
229
 
202
- config.chain = "polygon"
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: "polygon-amoy", chain_id: 80002, standard: "eip155")
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: "polygon-amoy",
284
+ chain: "arbitrum",
257
285
  symbol: "USDC",
258
- address: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582",
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