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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71561f4eae4f693705a405fd47e2b51a493441abd96dfe5a7d415915cdaa1313
4
- data.tar.gz: 251fa92d63aaaa4e944040192b55b1db1ed154cb26d62aaac7f86a200bbc5e07
3
+ metadata.gz: 5ec0c7a3137f3d98d641403e48438dc35677587631197581e3574804df7f42fa
4
+ data.tar.gz: f1d3d3ed62e675ad0c818e0202066c95cdddbd2c13ce6be3cec9ef04c1a47e94
5
5
  SHA512:
6
- metadata.gz: 753e8a4957ebbc21189826904e370ef90ae618aca1078c0889351aa6841106bffc237ee1a893eeba745c8d75773c30f25d083d4e033466ef2a0f3d327f4c239e
7
- data.tar.gz: 359cdbfd614777f9004a96aa4b2d5b9eff290266247707d9b886cafd1731bd3fa667625e641da16ea9383e5c3d2a3e74d79c0cf068a9be455ad4115a10715b19
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
- 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,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: true)
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 | `true` | Settlement mode (see Optimistic vs Non-Optimistic Mode below) |
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,
@@ -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
- request_body = {
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
- ::Rails.logger.info("=== X402 Settlement Request ===")
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("settle") do |req|
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
- ::Rails.logger.info("Response status: #{response.status}")
55
- ::Rails.logger.info("Response body: #{response.body}")
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 supported_networks
64
- response = connection.get("supported")
65
- handle_response(response, "supported")
66
- rescue Faraday::Error => e
67
- raise FacilitatorError, "Failed to fetch supported networks: #{e.message}"
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
@@ -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
- ::Rails.logger.info("=== X402 Settlement Attempt ===")
242
- ::Rails.logger.info("Optimistic mode: #{X402.configuration.optimistic}")
243
- ::Rails.logger.info("Payment payload class: #{payment_info[:payload].class}")
244
- ::Rails.logger.info("Payment payload: #{payment_info[:payload].inspect}")
245
- ::Rails.logger.info("Requirement class: #{payment_info[:requirement].class}")
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
- ::Rails.logger.info("x402 settlement successful: #{settlement_result.transaction}")
339
+ X402.logger.info("x402 settlement successful: #{settlement_result.transaction}")
260
340
  else
261
- ::Rails.logger.error("x402 settlement failed: #{settlement_result.error_reason}")
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
- ::Rails.logger.error("x402 settlement error: #{e.message}")
267
- nil
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module X402
4
4
  module Rails
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
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.1.0
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-04-10 00:00:00.000000000 Z
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