ruby-x402 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/README.md +102 -0
- data/docs/rails.md +60 -0
- data/lib/generators/x402/install_generator.rb +21 -0
- data/lib/generators/x402/templates/x402.rb +31 -0
- data/lib/x402/config.rb +120 -0
- data/lib/x402/encoding.rb +18 -0
- data/lib/x402/errors.rb +22 -0
- data/lib/x402/facilitator/engine.rb +24 -0
- data/lib/x402/facilitator/requests_controller.rb +68 -0
- data/lib/x402/facilitator_client.rb +155 -0
- data/lib/x402/rack/require_payment.rb +113 -0
- data/lib/x402/rails/controller_concern.rb +27 -0
- data/lib/x402/rails/settle_job.rb +30 -0
- data/lib/x402/railtie.rb +35 -0
- data/lib/x402/server/requirements.rb +74 -0
- data/lib/x402/types/networks.rb +19 -0
- data/lib/x402/types/payment_payload.rb +94 -0
- data/lib/x402/types/payment_requirements.rb +120 -0
- data/lib/x402/types/responses.rb +153 -0
- data/lib/x402/version.rb +5 -0
- data/lib/x402.rb +23 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 781025ab8d5eea01d68d2123dbfc9976fe140fbddd291604b1068d62507af5db
|
|
4
|
+
data.tar.gz: 305bfd02dcff64cd373f0d5871d5c6f6fa1ba55a65f2eafc166668a310743af8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 13c3bda6931660daaf0a2fce995048128c3e4e74bb4c970d484b8eaee2331f7b096e11c5b527e656d287b26d81939237faa1e0cbf421d17e66b14d5cb6e23730
|
|
7
|
+
data.tar.gz: 07d5904a21f8b2b419f0ee63caaebe8c2964a5e6a6797dc7af406926ed52d9e6f67eed57cc72b0dbd689c1ab7dec11e9ff19adf0a271c24173042ec2c316d461
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## 0.1.0 (2025-11-10)
|
|
2
|
+
|
|
3
|
+
- Rails integration:
|
|
4
|
+
- Railtie auto-inserts `X402::Rack::RequirePayment` and filters sensitive headers
|
|
5
|
+
- Installer generator with initializer template
|
|
6
|
+
- Optional controller concern `X402::Rails::ControllerConcern#require_x402_payment`
|
|
7
|
+
- Optional proxy facilitator Rails engine (`/verify`, `/settle`, `/discovery/resources`)
|
|
8
|
+
- Config API: `X402.configure` with validated settings and defaults
|
|
9
|
+
- Middleware: `:settle_async` mode using ActiveJob; `:verify_only` support
|
|
10
|
+
- FacilitatorClient: timeouts, retries, error mapping, and robust JSON handling
|
|
11
|
+
- Types: validation and error classes (`X402::Errors::*`)
|
|
12
|
+
- Docs: Rails quickstart and `docs/rails.md`
|
data/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# ruby-x402
|
|
2
|
+
|
|
3
|
+
Server-side Ruby implementation of the x402 payments protocol.
|
|
4
|
+
|
|
5
|
+
Includes:
|
|
6
|
+
|
|
7
|
+
- Rack middleware to require payments for routes
|
|
8
|
+
- Facilitator client to verify and settle payments
|
|
9
|
+
- Support for the `exact` scheme on EVM and Solana (SVM)
|
|
10
|
+
|
|
11
|
+
This gem does not provide client-side signing. Use a facilitator (e.g., `https://x402.org/facilitator`) for verify/settle.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add the gem to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "ruby-x402"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start (Rack)
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
use X402::Rack::RequirePayment, config: {
|
|
25
|
+
networks: ["base-sepolia", "solana-devnet"],
|
|
26
|
+
routes: {
|
|
27
|
+
"/paid" => {
|
|
28
|
+
evm: { amount: "1000000" },
|
|
29
|
+
svm: { amount: "1000000" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
evm: {
|
|
33
|
+
asset: "0x036CbD53842c...",
|
|
34
|
+
pay_to: "0xYourEvmAddress",
|
|
35
|
+
extra: { name: "USDC", version: "2" }
|
|
36
|
+
},
|
|
37
|
+
svm: {
|
|
38
|
+
asset: "EPjFWdd5Aufq...",
|
|
39
|
+
pay_to: "YourSolanaAddress",
|
|
40
|
+
extra: { fee_payer: "FacilitatorFeePayerAddress" }
|
|
41
|
+
},
|
|
42
|
+
facilitator: { url: "https://x402.org/facilitator" }
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start (Rails)
|
|
47
|
+
|
|
48
|
+
1. Add the gem and bundle.
|
|
49
|
+
2. Run the installer:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
bin/rails g x402:install
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This creates `config/initializers/x402.rb`. Adjust networks, pricing, and facilitator settings.
|
|
56
|
+
|
|
57
|
+
3. (Optional) Mount the proxy facilitator engine to forward `/verify`, `/settle`, and discovery to an upstream:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# config/routes.rb
|
|
61
|
+
mount X402::Facilitator::Engine => "/x402"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
4. Modes:
|
|
65
|
+
- `:settle_sync` (default): verify then settle synchronously; returns `X-PAYMENT-RESPONSE` header
|
|
66
|
+
- `:verify_only`: verify only, no settlement
|
|
67
|
+
- `:settle_async`: verify then enqueue settlement via ActiveJob (if available)
|
|
68
|
+
|
|
69
|
+
## Facilitator client
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
client = X402::FacilitatorClient.new(
|
|
73
|
+
url: "https://x402.org/facilitator",
|
|
74
|
+
create_headers: -> {
|
|
75
|
+
{ verify: { "Authorization" => "Bearer <token>" } }
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
payment = X402::Types::PaymentPayload.new(
|
|
80
|
+
x402_version: 1, scheme: "exact", network: "base-sepolia",
|
|
81
|
+
payload: { "authorization" => {}, "signature" => "0x" }
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
reqs = X402::Types::PaymentRequirements.new(
|
|
85
|
+
scheme: "exact", network: "base-sepolia", max_amount_required: "1000000",
|
|
86
|
+
resource: "/paid", description: "API access", mime_type: "application/json",
|
|
87
|
+
pay_to: "0x...", max_timeout_seconds: 60, asset: "0x...", extra: { "name" => "USDC", "version" => "2" }
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
verify = client.verify(payment: payment, payment_requirements: reqs)
|
|
91
|
+
if verify.is_valid
|
|
92
|
+
settle = client.settle(payment: payment, payment_requirements: reqs)
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Docs
|
|
97
|
+
|
|
98
|
+
- See `docs/rails.md` for Rails integration details and troubleshooting.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
Apache-2.0
|
data/docs/rails.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Rails integration
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bundle add ruby-x402
|
|
7
|
+
bin/rails g x402:install
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
This adds `config/initializers/x402.rb` with sensible defaults.
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
Use `X402.configure`:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
X402.configure do |c|
|
|
18
|
+
c.networks = %w[base-sepolia solana-devnet]
|
|
19
|
+
c.routes = { "/paid" => { evm: { amount: "1000000" }, svm: { amount: "1000000" } } }
|
|
20
|
+
c.evm.asset = ENV["X402_EVM_ASSET"]
|
|
21
|
+
c.evm.pay_to = ENV["X402_EVM_PAY_TO"]
|
|
22
|
+
c.svm.asset = ENV["X402_SVM_ASSET"]
|
|
23
|
+
c.svm.pay_to = ENV["X402_SVM_PAY_TO"]
|
|
24
|
+
c.facilitator.url = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
|
|
25
|
+
c.facilitator.create_headers = -> { { verify: { "Authorization" => "Bearer #{ENV["X402_FACILITATOR_TOKEN"]}" } } }
|
|
26
|
+
c.mode = :settle_sync # or :verify_only, :settle_async
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The Railtie auto-inserts the `X402::Rack::RequirePayment` middleware using the configured settings.
|
|
31
|
+
|
|
32
|
+
## Proxy facilitator engine (optional)
|
|
33
|
+
|
|
34
|
+
Mount the engine to forward requests to a configured upstream facilitator:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/routes.rb
|
|
38
|
+
mount X402::Facilitator::Engine => "/x402"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Endpoints:
|
|
42
|
+
- POST `/x402/verify`
|
|
43
|
+
- POST `/x402/settle`
|
|
44
|
+
- GET `/x402/discovery/resources`
|
|
45
|
+
|
|
46
|
+
## Modes
|
|
47
|
+
- `:settle_sync` (default): verify then settle synchronously; sets `X-PAYMENT-RESPONSE`
|
|
48
|
+
- `:verify_only`: verify only
|
|
49
|
+
- `:settle_async`: verify, then enqueue settlement with `ActiveJob` if present
|
|
50
|
+
|
|
51
|
+
## Security and logging
|
|
52
|
+
- `X-PAYMENT` and `X-PAYMENT-RESPONSE` are filtered from Rails logs by default.
|
|
53
|
+
- CORS: Ensure `Access-Control-Expose-Headers` includes `X-PAYMENT-RESPONSE` (middleware sets it).
|
|
54
|
+
|
|
55
|
+
## Troubleshooting
|
|
56
|
+
- `invalid_x402_version`, `invalid_network`, `invalid_scheme`: Check payload formation and network selection.
|
|
57
|
+
- `unsupported network` during boot: Ensure `c.networks` is one of `X402::Types::Networks::ALL`.
|
|
58
|
+
- Settlement not appearing with `:settle_async`: Ensure ActiveJob queue is running, or use `:settle_sync`.
|
|
59
|
+
|
|
60
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "rails/generators"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# Rails not available
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module X402
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < (defined?(Rails::Generators::Base) ? Rails::Generators::Base : Object)
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def create_initializer
|
|
13
|
+
return unless defined?(Rails::Generators::Base)
|
|
14
|
+
template "x402.rb", "config/initializers/x402.rb"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
X402.configure do |c|
|
|
2
|
+
c.networks = %w[base-sepolia solana-devnet]
|
|
3
|
+
c.routes = {
|
|
4
|
+
"/paid" => {
|
|
5
|
+
evm: { amount: "1000000" },
|
|
6
|
+
svm: { amount: "1000000" }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# EVM network settings
|
|
11
|
+
c.evm.asset = ENV.fetch("X402_EVM_ASSET", nil)
|
|
12
|
+
c.evm.pay_to = ENV.fetch("X402_EVM_PAY_TO", nil)
|
|
13
|
+
c.evm.extra = { name: "USDC", version: "2" }
|
|
14
|
+
|
|
15
|
+
# SVM network settings
|
|
16
|
+
c.svm.asset = ENV.fetch("X402_SVM_ASSET", nil)
|
|
17
|
+
c.svm.pay_to = ENV.fetch("X402_SVM_PAY_TO", nil)
|
|
18
|
+
c.svm.extra = { fee_payer: ENV.fetch("X402_SVM_FEE_PAYER", nil) }.compact
|
|
19
|
+
|
|
20
|
+
# Facilitator settings
|
|
21
|
+
c.facilitator.url = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/protected")
|
|
22
|
+
c.facilitator.create_headers = lambda {
|
|
23
|
+
token = ENV["X402_FACILITATOR_TOKEN"]
|
|
24
|
+
token ? { verify: { "Authorization" => "Bearer #{token}" }, settle: { "Authorization" => "Bearer #{token}" } } : {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
c.mode = :settle_sync # :verify_only or :settle_async
|
|
28
|
+
c.max_timeout_seconds = 60
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
|
data/lib/x402/config.rb
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require_relative "types/networks"
|
|
2
|
+
require_relative "errors"
|
|
3
|
+
|
|
4
|
+
module X402
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_MODE = :settle_sync
|
|
7
|
+
DEFAULT_TIMEOUT = 60
|
|
8
|
+
|
|
9
|
+
class NetworkSettings
|
|
10
|
+
attr_accessor :asset, :pay_to, :extra
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@asset = nil
|
|
14
|
+
@pay_to = nil
|
|
15
|
+
@extra = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
asset: asset,
|
|
21
|
+
pay_to: pay_to,
|
|
22
|
+
extra: extra || {}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class FacilitatorSettings
|
|
28
|
+
attr_accessor :url, :create_headers
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@url = X402::FacilitatorClient::DEFAULT_URL
|
|
32
|
+
@create_headers = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
url: url,
|
|
38
|
+
create_headers: create_headers
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_accessor :networks, :routes, :mode, :max_timeout_seconds
|
|
44
|
+
attr_reader :evm, :svm, :facilitator
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@networks = []
|
|
48
|
+
@routes = {}
|
|
49
|
+
@evm = NetworkSettings.new
|
|
50
|
+
@svm = NetworkSettings.new
|
|
51
|
+
@facilitator = FacilitatorSettings.new
|
|
52
|
+
@mode = DEFAULT_MODE
|
|
53
|
+
@max_timeout_seconds = DEFAULT_TIMEOUT
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def routes=(value)
|
|
57
|
+
@routes = deep_symbolize(value || {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate!
|
|
61
|
+
unless mode.is_a?(Symbol) && %i[settle_sync verify_only settle_async].include?(mode)
|
|
62
|
+
raise X402::Errors::ConfigurationError, "Invalid mode: #{mode.inspect}"
|
|
63
|
+
end
|
|
64
|
+
unless max_timeout_seconds.is_a?(Integer) && max_timeout_seconds.positive?
|
|
65
|
+
raise X402::Errors::ConfigurationError, "max_timeout_seconds must be a positive Integer"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
networks.each do |n|
|
|
69
|
+
unless X402::Types::Networks::ALL.include?(n)
|
|
70
|
+
raise X402::Errors::ValidationError, "Unsupported network: #{n}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if networks.any? { |n| n.include?("base") }
|
|
75
|
+
%i[asset pay_to].each do |k|
|
|
76
|
+
v = evm.public_send(k)
|
|
77
|
+
raise X402::Errors::ConfigurationError, "EVM #{k} must be configured" if v.nil? || v.to_s.empty?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
if networks.any? { |n| n.start_with?("solana") }
|
|
81
|
+
%i[asset pay_to].each do |k|
|
|
82
|
+
v = svm.public_send(k)
|
|
83
|
+
raise X402::Errors::ConfigurationError, "SVM #{k} must be configured" if v.nil? || v.to_s.empty?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_h
|
|
90
|
+
{
|
|
91
|
+
networks: networks,
|
|
92
|
+
routes: routes,
|
|
93
|
+
evm: evm.to_h,
|
|
94
|
+
svm: svm.to_h,
|
|
95
|
+
facilitator: facilitator.to_h,
|
|
96
|
+
mode: mode,
|
|
97
|
+
max_timeout_seconds: max_timeout_seconds
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def deep_symbolize(obj)
|
|
104
|
+
case obj
|
|
105
|
+
when Hash
|
|
106
|
+
obj.each_with_object({}) do |(k, v), acc|
|
|
107
|
+
key = (k.to_sym rescue k) || k
|
|
108
|
+
acc[key] = deep_symbolize(v)
|
|
109
|
+
end
|
|
110
|
+
when Array
|
|
111
|
+
obj.map { |v| deep_symbolize(v) }
|
|
112
|
+
else
|
|
113
|
+
obj
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module X402
|
|
5
|
+
module Encoding
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def encode_header(hash)
|
|
9
|
+
json = JSON.generate(hash)
|
|
10
|
+
Base64.strict_encode64(json)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def decode_header(base64_string)
|
|
14
|
+
json = Base64.decode64(base64_string.to_s)
|
|
15
|
+
JSON.parse(json)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/x402/errors.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module X402
|
|
2
|
+
module Errors
|
|
3
|
+
class BaseError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class ConfigurationError < BaseError; end
|
|
6
|
+
class ValidationError < BaseError; end
|
|
7
|
+
|
|
8
|
+
class NetworkError < BaseError; end
|
|
9
|
+
class TimeoutError < NetworkError; end
|
|
10
|
+
class HttpError < NetworkError
|
|
11
|
+
attr_reader :status, :body
|
|
12
|
+
def initialize(message = "HTTP error", status: nil, body: nil)
|
|
13
|
+
@status = status
|
|
14
|
+
@body = body
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
class ParseError < BaseError; end
|
|
19
|
+
|
|
20
|
+
class UnsupportedError < BaseError; end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "rails/engine"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# Rails not available
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module X402
|
|
8
|
+
module Facilitator
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
isolate_namespace X402::Facilitator
|
|
11
|
+
|
|
12
|
+
initializer "x402.facilitator.routes" do
|
|
13
|
+
X402::Facilitator::Engine.routes.draw do
|
|
14
|
+
post "/verify", to: "requests#verify"
|
|
15
|
+
post "/settle", to: "requests#settle"
|
|
16
|
+
get "/discovery/resources", to: "requests#list"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "action_controller/railtie"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# Rails not available
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "../facilitator_client"
|
|
8
|
+
require_relative "../types/payment_payload"
|
|
9
|
+
require_relative "../types/payment_requirements"
|
|
10
|
+
require_relative "../errors"
|
|
11
|
+
|
|
12
|
+
module X402
|
|
13
|
+
module Facilitator
|
|
14
|
+
class RequestsController < (defined?(::ActionController::API) ? ::ActionController::API : ::ActionController::Base)
|
|
15
|
+
def verify
|
|
16
|
+
client = build_client
|
|
17
|
+
payload, reqs = parse_payload_and_requirements
|
|
18
|
+
render json: client.verify(payment: payload, payment_requirements: reqs).to_h
|
|
19
|
+
rescue X402::Errors::BaseError => e
|
|
20
|
+
render json: { isValid: false, invalidReason: e.class.name.split("::").last.underscore }, status: :bad_request
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
render json: { isValid: false, invalidReason: "unexpected_verify_error" }, status: :internal_server_error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def settle
|
|
26
|
+
client = build_client
|
|
27
|
+
payload, reqs = parse_payload_and_requirements
|
|
28
|
+
render json: client.settle(payment: payload, payment_requirements: reqs).to_h
|
|
29
|
+
rescue X402::Errors::BaseError => e
|
|
30
|
+
render json: { success: false, errorReason: e.class.name.split("::").last.underscore }, status: :bad_request
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
render json: { success: false, errorReason: "unexpected_settle_error" }, status: :internal_server_error
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def list
|
|
36
|
+
client = build_client
|
|
37
|
+
items = client.list(params: request.query_parameters)
|
|
38
|
+
render json: items
|
|
39
|
+
rescue X402::Errors::BaseError => e
|
|
40
|
+
render json: { error: e.message }, status: :bad_request
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
render json: { error: "unexpected_list_error" }, status: :internal_server_error
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_client
|
|
48
|
+
::X402::FacilitatorClient.new(
|
|
49
|
+
url: ::X402.config.facilitator.url,
|
|
50
|
+
create_headers: ::X402.config.facilitator.create_headers
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_payload_and_requirements
|
|
55
|
+
body = request.request_parameters.presence || JSON.parse(request.raw_post)
|
|
56
|
+
payload = body["paymentPayload"] || body["payment_payload"]
|
|
57
|
+
requirements = body["paymentRequirements"] || body["payment_requirements"]
|
|
58
|
+
[
|
|
59
|
+
::X402::Types::PaymentPayload.from_hash(payload),
|
|
60
|
+
::X402::Types::PaymentRequirements.from_hash(requirements)
|
|
61
|
+
]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "types/payment_payload"
|
|
5
|
+
require_relative "types/payment_requirements"
|
|
6
|
+
require_relative "types/responses"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module X402
|
|
10
|
+
class FacilitatorClient
|
|
11
|
+
DEFAULT_URL = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/protected").freeze
|
|
12
|
+
|
|
13
|
+
def initialize(url: DEFAULT_URL, create_headers: nil, open_timeout: 5, read_timeout: 10, write_timeout: 5, retry_count: 1)
|
|
14
|
+
raise ArgumentError, "Invalid URL #{url}" unless url.to_s.start_with?("http://", "https://")
|
|
15
|
+
@base_url = url.end_with?("/") ? url[0..-2] : url
|
|
16
|
+
@create_headers = create_headers # -> { verify: {..}, settle: {..}, list: {..} }
|
|
17
|
+
@open_timeout = Integer(open_timeout)
|
|
18
|
+
@read_timeout = Integer(read_timeout)
|
|
19
|
+
@write_timeout = Integer(write_timeout) rescue 5
|
|
20
|
+
@retry_count = Integer(retry_count)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verify(payment:, payment_requirements:)
|
|
24
|
+
body = {
|
|
25
|
+
"x402Version" => normalize_payload(payment).x402_version,
|
|
26
|
+
"paymentPayload" => normalize_payload(payment).to_h,
|
|
27
|
+
"paymentRequirements" => normalize_requirements(payment_requirements).to_h
|
|
28
|
+
}
|
|
29
|
+
headers = base_headers.merge(endpoint_headers(:verify))
|
|
30
|
+
data = post_json("#{@base_url}/verify", body, headers)
|
|
31
|
+
Types::VerifyResponse.from_hash(data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def settle(payment:, payment_requirements:)
|
|
35
|
+
body = {
|
|
36
|
+
"x402Version" => normalize_payload(payment).x402_version,
|
|
37
|
+
"paymentPayload" => normalize_payload(payment).to_h,
|
|
38
|
+
"paymentRequirements" => normalize_requirements(payment_requirements).to_h
|
|
39
|
+
}
|
|
40
|
+
headers = base_headers.merge(endpoint_headers(:settle))
|
|
41
|
+
data = post_json("#{@base_url}/settle", body, headers)
|
|
42
|
+
Types::SettleResponse.from_hash(data)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list(params: {})
|
|
46
|
+
headers = base_headers.merge(endpoint_headers(:list))
|
|
47
|
+
uri = URI("#{@base_url}/discovery/resources")
|
|
48
|
+
uri.query = URI.encode_www_form(params.transform_keys { |k| camelize(k.to_s) })
|
|
49
|
+
res = with_retries { http_get(uri, headers) }
|
|
50
|
+
ensure_success!(res)
|
|
51
|
+
parse_json(res.body)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def base_headers
|
|
57
|
+
{ "Content-Type" => "application/json" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def endpoint_headers(kind)
|
|
61
|
+
return {} unless @create_headers
|
|
62
|
+
custom = @create_headers.call
|
|
63
|
+
(custom[kind] || {}) || {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def http_post(uri, body, headers)
|
|
67
|
+
uri = URI(uri)
|
|
68
|
+
req = Net::HTTP::Post.new(uri)
|
|
69
|
+
headers.each { |k, v| req[k] = v }
|
|
70
|
+
req.body = body
|
|
71
|
+
perform_http(uri, req)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def http_get(uri, headers)
|
|
75
|
+
req = Net::HTTP::Get.new(uri)
|
|
76
|
+
headers.each { |k, v| req[k] = v }
|
|
77
|
+
perform_http(uri, req)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def post_json(uri, obj, headers)
|
|
81
|
+
res = http_post(uri, JSON.generate(obj), headers)
|
|
82
|
+
ensure_success!(res)
|
|
83
|
+
parse_json(res.body)
|
|
84
|
+
rescue JSON::ParserError => e
|
|
85
|
+
raise X402::Errors::ParseError, "Invalid JSON response: #{e.message}"
|
|
86
|
+
rescue Timeout::Error => e
|
|
87
|
+
raise X402::Errors::TimeoutError, e.message
|
|
88
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
89
|
+
raise X402::Errors::NetworkError, e.message
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_payload(obj)
|
|
93
|
+
case obj
|
|
94
|
+
when Types::PaymentPayload then obj
|
|
95
|
+
when String then Types::PaymentPayload.from_json(obj)
|
|
96
|
+
when Hash then Types::PaymentPayload.from_hash(obj)
|
|
97
|
+
else
|
|
98
|
+
raise ArgumentError, "Unsupported payment payload type: #{obj.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_requirements(obj)
|
|
103
|
+
case obj
|
|
104
|
+
when Types::PaymentRequirements then obj
|
|
105
|
+
when String then Types::PaymentRequirements.from_json(obj)
|
|
106
|
+
when Hash then Types::PaymentRequirements.from_hash(obj)
|
|
107
|
+
else
|
|
108
|
+
raise ArgumentError, "Unsupported payment requirements type: #{obj.class}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def camelize(key)
|
|
113
|
+
parts = key.split("_")
|
|
114
|
+
parts[0] + parts[1..].map(&:capitalize).join
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def perform_http(uri, req)
|
|
118
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
119
|
+
http.open_timeout = @open_timeout if http.respond_to?(:open_timeout=)
|
|
120
|
+
http.read_timeout = @read_timeout if http.respond_to?(:read_timeout=)
|
|
121
|
+
http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
|
|
122
|
+
http.request(req)
|
|
123
|
+
end
|
|
124
|
+
rescue Timeout::Error => e
|
|
125
|
+
raise X402::Errors::TimeoutError, e.message
|
|
126
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
127
|
+
raise X402::Errors::NetworkError, e.message
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def with_retries
|
|
131
|
+
attempts = 0
|
|
132
|
+
begin
|
|
133
|
+
yield
|
|
134
|
+
rescue X402::Errors::TimeoutError, X402::Errors::NetworkError => e
|
|
135
|
+
attempts += 1
|
|
136
|
+
raise e if attempts > @retry_count
|
|
137
|
+
sleep(0.2 * attempts)
|
|
138
|
+
retry
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ensure_success!(res)
|
|
143
|
+
return if res.is_a?(Net::HTTPSuccess)
|
|
144
|
+
raise X402::Errors::HttpError.new("HTTP #{res.code}", status: res.code.to_i, body: res.body)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_json(body)
|
|
148
|
+
JSON.parse(body)
|
|
149
|
+
rescue JSON::ParserError => e
|
|
150
|
+
raise X402::Errors::ParseError, "Invalid JSON response: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
|