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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../encoding"
|
|
3
|
+
require_relative "../facilitator_client"
|
|
4
|
+
require_relative "../server/requirements"
|
|
5
|
+
require_relative "../types/payment_payload"
|
|
6
|
+
|
|
7
|
+
module X402
|
|
8
|
+
module Rack
|
|
9
|
+
class RequirePayment
|
|
10
|
+
X402_VERSION = 1
|
|
11
|
+
|
|
12
|
+
def initialize(app, config:)
|
|
13
|
+
@app = app
|
|
14
|
+
@config = symbolize_keys(config || {})
|
|
15
|
+
@client = X402::FacilitatorClient.new(
|
|
16
|
+
url: @config.dig(:facilitator, :url) || X402::FacilitatorClient::DEFAULT_URL,
|
|
17
|
+
create_headers: @config.dig(:facilitator, :create_headers)
|
|
18
|
+
)
|
|
19
|
+
@mode = (@config[:mode] || :settle_sync).to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(env)
|
|
23
|
+
req_path = env["PATH_INFO"].to_s
|
|
24
|
+
accepts = X402::Server::Requirements.build_for(path: req_path, config: @config)
|
|
25
|
+
return @app.call(env) if accepts.empty?
|
|
26
|
+
|
|
27
|
+
x_payment = env["HTTP_X_PAYMENT"]
|
|
28
|
+
unless x_payment && !x_payment.empty?
|
|
29
|
+
return payment_required_response(accepts, "X-PAYMENT header is required")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
decoded = X402::Encoding.decode_header(x_payment)
|
|
34
|
+
payload = X402::Types::PaymentPayload.from_hash(decoded)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
return payment_required_response(accepts, "Invalid X-PAYMENT header: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
selected = select_requirements_for_payload(accepts, payload)
|
|
40
|
+
unless selected
|
|
41
|
+
return payment_required_response(accepts, "Unsupported scheme or network for this route")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
verify = @client.verify(payment: payload, payment_requirements: selected)
|
|
45
|
+
unless verify.is_valid
|
|
46
|
+
return payment_required_response(accepts, "Payment verification failed: #{verify.invalid_reason || "invalid"}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
x_payment_response = nil
|
|
50
|
+
if @mode == :settle_sync
|
|
51
|
+
settle = @client.settle(payment: payload, payment_requirements: selected)
|
|
52
|
+
x_payment_response = X402::Encoding.encode_header(settle.to_h)
|
|
53
|
+
elsif @mode == :settle_async && defined?(::ActiveJob::Base)
|
|
54
|
+
# Enqueue background settlement; response header will not be populated synchronously
|
|
55
|
+
begin
|
|
56
|
+
X402::Rails::SettleJob.perform_later(payload: payload.to_h, payment_requirements: selected.to_h)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
# If enqueue fails, fall back to sync settle
|
|
59
|
+
settle = @client.settle(payment: payload, payment_requirements: selected)
|
|
60
|
+
x_payment_response = X402::Encoding.encode_header(settle.to_h)
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
# :verify_only or unknown mode: do nothing beyond verification
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
status, headers, body = @app.call(env)
|
|
67
|
+
headers = headers.dup
|
|
68
|
+
if x_payment_response
|
|
69
|
+
headers["X-PAYMENT-RESPONSE"] = x_payment_response
|
|
70
|
+
end
|
|
71
|
+
headers["Access-Control-Expose-Headers"] = [headers["Access-Control-Expose-Headers"], "X-PAYMENT-RESPONSE"].compact.join(", ")
|
|
72
|
+
[status, headers, body]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def payment_required_response(accepts, error)
|
|
78
|
+
body_hash = {
|
|
79
|
+
"x402Version" => X402_VERSION,
|
|
80
|
+
"error" => error,
|
|
81
|
+
"accepts" => accepts.map(&:to_h)
|
|
82
|
+
}
|
|
83
|
+
body = JSON.generate(body_hash)
|
|
84
|
+
headers = {
|
|
85
|
+
"Content-Type" => "application/json",
|
|
86
|
+
"Access-Control-Expose-Headers" => "X-PAYMENT-RESPONSE"
|
|
87
|
+
}
|
|
88
|
+
[402, headers, [body]]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def select_requirements_for_payload(accepts, payload)
|
|
92
|
+
accepts.find do |req|
|
|
93
|
+
req.scheme == payload.scheme && req.network == payload.network
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def symbolize_keys(obj)
|
|
98
|
+
case obj
|
|
99
|
+
when Hash
|
|
100
|
+
obj.each_with_object({}) do |(k, v), acc|
|
|
101
|
+
acc[(k.to_sym rescue k) || k] = symbolize_keys(v)
|
|
102
|
+
end
|
|
103
|
+
when Array
|
|
104
|
+
obj.map { |v| symbolize_keys(v) }
|
|
105
|
+
else
|
|
106
|
+
obj
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "active_support/concern"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# ActiveSupport not available
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module X402
|
|
8
|
+
module Rails
|
|
9
|
+
module ControllerConcern
|
|
10
|
+
extend ::ActiveSupport::Concern if defined?(::ActiveSupport::Concern)
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def require_x402_payment(path:, evm: nil, svm: nil, description: nil, mime_type: nil)
|
|
14
|
+
return unless defined?(::ActiveSupport::Concern)
|
|
15
|
+
routes = X402.config.routes.dup
|
|
16
|
+
routes[path] ||= {}
|
|
17
|
+
routes[path][:evm] = (evm || {}).merge(description: description, mime_type: mime_type).compact if evm
|
|
18
|
+
routes[path][:svm] = (svm || {}).merge(description: description, mime_type: mime_type).compact if svm
|
|
19
|
+
X402.config.routes = routes
|
|
20
|
+
end
|
|
21
|
+
end if respond_to?(:class_methods, true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "active_job"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# ActiveJob not available
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "../facilitator_client"
|
|
8
|
+
|
|
9
|
+
module X402
|
|
10
|
+
module Rails
|
|
11
|
+
class SettleJob < (defined?(::ActiveJob::Base) ? ::ActiveJob::Base : Object)
|
|
12
|
+
queue_as :default if respond_to?(:queue_as)
|
|
13
|
+
|
|
14
|
+
def perform(payload:, payment_requirements:)
|
|
15
|
+
return unless defined?(::ActiveJob::Base)
|
|
16
|
+
client = ::X402::FacilitatorClient.new(
|
|
17
|
+
url: ::X402.config.facilitator.url,
|
|
18
|
+
create_headers: ::X402.config.facilitator.create_headers
|
|
19
|
+
)
|
|
20
|
+
client.settle(
|
|
21
|
+
payment: ::X402::Types::PaymentPayload.from_hash(payload),
|
|
22
|
+
payment_requirements: ::X402::Types::PaymentRequirements.from_hash(payment_requirements)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
data/lib/x402/railtie.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "rails/railtie"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# Rails not available; Railtie will not be loaded
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "rack/require_payment"
|
|
8
|
+
|
|
9
|
+
module X402
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
initializer "x402.configure_middleware" do |app|
|
|
12
|
+
# Ensure config is valid before inserting middleware
|
|
13
|
+
begin
|
|
14
|
+
X402.config.validate!
|
|
15
|
+
rescue StandardError
|
|
16
|
+
# Defer validation errors until first use; don't crash boot in development
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
app.config.middleware.insert_after(
|
|
20
|
+
defined?(ActionDispatch::Executor) ? ActionDispatch::Executor : 0,
|
|
21
|
+
X402::Rack::RequirePayment,
|
|
22
|
+
config: X402.config.to_h
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "x402.filter_parameters" do |app|
|
|
27
|
+
if app.config.respond_to?(:filter_parameters)
|
|
28
|
+
app.config.filter_parameters |= [:x_payment, :x_payment_response]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative "../types/payment_requirements"
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Server
|
|
5
|
+
class Requirements
|
|
6
|
+
DEFAULT_TIMEOUT = 60
|
|
7
|
+
|
|
8
|
+
# Build an array of PaymentRequirements for a given request path
|
|
9
|
+
# config:
|
|
10
|
+
# {
|
|
11
|
+
# networks: ["base-sepolia", "solana-devnet"],
|
|
12
|
+
# routes: {
|
|
13
|
+
# "/paid" => { evm: { amount: "1000000" }, svm: { amount: "1000000" } }
|
|
14
|
+
# },
|
|
15
|
+
# evm: { asset: "0x...", pay_to: "0x...", extra: { name: "USDC", version: "2" } },
|
|
16
|
+
# svm: { asset: "EPjF...", pay_to: "SoL...", extra: { fee_payer: "SoL..." } },
|
|
17
|
+
# max_timeout_seconds: 60
|
|
18
|
+
# }
|
|
19
|
+
def self.build_for(path:, config:)
|
|
20
|
+
route = find_route_config(path, config.fetch(:routes, {}))
|
|
21
|
+
return [] unless route
|
|
22
|
+
|
|
23
|
+
accepts = []
|
|
24
|
+
timeout = (config[:max_timeout_seconds] || DEFAULT_TIMEOUT).to_i
|
|
25
|
+
|
|
26
|
+
if config.fetch(:networks, []).any? { |n| n.include?("base") } && route[:evm]
|
|
27
|
+
accepts << X402::Types::PaymentRequirements.new(
|
|
28
|
+
scheme: "exact",
|
|
29
|
+
network: config[:networks].find { |n| n.include?("base") },
|
|
30
|
+
max_amount_required: route[:evm][:amount].to_s,
|
|
31
|
+
resource: path,
|
|
32
|
+
description: route[:evm][:description] || "Paid resource",
|
|
33
|
+
mime_type: route[:evm][:mime_type] || "application/json",
|
|
34
|
+
pay_to: config.dig(:evm, :pay_to),
|
|
35
|
+
max_timeout_seconds: timeout,
|
|
36
|
+
asset: config.dig(:evm, :asset),
|
|
37
|
+
extra: config.dig(:evm, :extra) || {}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if config.fetch(:networks, []).any? { |n| n.start_with?("solana") } && route[:svm]
|
|
42
|
+
accepts << X402::Types::PaymentRequirements.new(
|
|
43
|
+
scheme: "exact",
|
|
44
|
+
network: config[:networks].find { |n| n.start_with?("solana") },
|
|
45
|
+
max_amount_required: route[:svm][:amount].to_s,
|
|
46
|
+
resource: path,
|
|
47
|
+
description: route[:svm][:description] || "Paid resource",
|
|
48
|
+
mime_type: route[:svm][:mime_type] || "application/json",
|
|
49
|
+
pay_to: config.dig(:svm, :pay_to),
|
|
50
|
+
max_timeout_seconds: timeout,
|
|
51
|
+
asset: config.dig(:svm, :asset),
|
|
52
|
+
extra: config.dig(:svm, :extra) || {}
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
accepts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.find_route_config(path, routes)
|
|
60
|
+
# exact match (string or symbol) or first regex match
|
|
61
|
+
return routes[path] if routes.key?(path)
|
|
62
|
+
sym_key = (path.to_sym rescue nil)
|
|
63
|
+
return routes[sym_key] if sym_key && routes.key?(sym_key)
|
|
64
|
+
key = routes.keys.find do |k|
|
|
65
|
+
k.is_a?(Regexp) ? k.match?(path) : false
|
|
66
|
+
end
|
|
67
|
+
key ? routes[key] : nil
|
|
68
|
+
end
|
|
69
|
+
private_class_method :find_route_config
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class PaymentPayload
|
|
6
|
+
attr_reader :x402_version, :scheme, :network, :payload
|
|
7
|
+
|
|
8
|
+
def initialize(x402_version:, scheme:, network:, payload:)
|
|
9
|
+
@x402_version = Integer(x402_version)
|
|
10
|
+
@scheme = scheme
|
|
11
|
+
@network = network
|
|
12
|
+
@payload = payload # Hash; scheme-specific
|
|
13
|
+
validate!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_hash(hash)
|
|
17
|
+
h = stringify_keys(hash)
|
|
18
|
+
new(
|
|
19
|
+
x402_version: h["x402Version"] || h["x402_version"],
|
|
20
|
+
scheme: h["scheme"],
|
|
21
|
+
network: h["network"],
|
|
22
|
+
payload: h["payload"] || {}
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.from_json(json)
|
|
27
|
+
from_hash(JSON.parse(json))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
"x402Version" => x402_version,
|
|
33
|
+
"scheme" => scheme,
|
|
34
|
+
"network" => network,
|
|
35
|
+
"payload" => payload
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_json(*args)
|
|
40
|
+
JSON.generate(to_h, *args)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exact_evm?
|
|
44
|
+
scheme == "exact" && network.include?("base")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exact_svm?
|
|
48
|
+
scheme == "exact" && network.start_with?("solana")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def stringify_keys(obj)
|
|
55
|
+
case obj
|
|
56
|
+
when Hash
|
|
57
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify_keys(v) }
|
|
58
|
+
when Array
|
|
59
|
+
obj.map { |v| stringify_keys(v) }
|
|
60
|
+
else
|
|
61
|
+
obj
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def validate!
|
|
69
|
+
unless x402_version == 1
|
|
70
|
+
raise X402::Errors::ValidationError, "invalid_x402_version"
|
|
71
|
+
end
|
|
72
|
+
unless X402::Types::Networks::ALL.include?(network)
|
|
73
|
+
raise X402::Errors::ValidationError, "invalid_network"
|
|
74
|
+
end
|
|
75
|
+
unless scheme == "exact"
|
|
76
|
+
raise X402::Errors::ValidationError, "invalid_scheme"
|
|
77
|
+
end
|
|
78
|
+
unless payload.is_a?(Hash)
|
|
79
|
+
raise X402::Errors::ValidationError, "invalid_payload"
|
|
80
|
+
end
|
|
81
|
+
if exact_evm?
|
|
82
|
+
auth = payload["authorization"] || payload[:authorization]
|
|
83
|
+
sig = payload["signature"] || payload[:signature]
|
|
84
|
+
required = %w[from to value validAfter validBefore nonce]
|
|
85
|
+
if sig.to_s.empty? || !auth.is_a?(Hash) || required.any? { |k| (auth[k] || auth[k.to_sym]).to_s.empty? }
|
|
86
|
+
raise X402::Errors::ValidationError, "invalid_payload"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class PaymentRequirements
|
|
6
|
+
attr_reader :scheme, :network, :max_amount_required, :resource,
|
|
7
|
+
:description, :mime_type, :output_schema, :pay_to,
|
|
8
|
+
:max_timeout_seconds, :asset, :extra
|
|
9
|
+
|
|
10
|
+
def initialize(scheme:, network:, max_amount_required:, resource:,
|
|
11
|
+
description:, mime_type:, pay_to:, max_timeout_seconds:,
|
|
12
|
+
asset:, output_schema: nil, extra: nil)
|
|
13
|
+
@scheme = scheme
|
|
14
|
+
@network = network
|
|
15
|
+
@max_amount_required = max_amount_required.to_s
|
|
16
|
+
@resource = resource
|
|
17
|
+
@description = description
|
|
18
|
+
@mime_type = mime_type
|
|
19
|
+
@output_schema = output_schema
|
|
20
|
+
@pay_to = pay_to
|
|
21
|
+
@max_timeout_seconds = Integer(max_timeout_seconds)
|
|
22
|
+
@asset = asset
|
|
23
|
+
@extra = extra || {}
|
|
24
|
+
validate!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.from_hash(hash)
|
|
28
|
+
|
|
29
|
+
h = stringify_keys(hash)
|
|
30
|
+
new(
|
|
31
|
+
scheme: h["scheme"],
|
|
32
|
+
network: h["network"],
|
|
33
|
+
max_amount_required: h["maxAmountRequired"] || h["max_amount_required"],
|
|
34
|
+
resource: h["resource"],
|
|
35
|
+
description: h["description"],
|
|
36
|
+
mime_type: h["mimeType"] || h["mime_type"],
|
|
37
|
+
output_schema: h["outputSchema"] || h["output_schema"],
|
|
38
|
+
pay_to: h["payTo"] || h["pay_to"],
|
|
39
|
+
max_timeout_seconds: h["maxTimeoutSeconds"] || h["max_timeout_seconds"],
|
|
40
|
+
asset: h["asset"],
|
|
41
|
+
extra: h["extra"] || {}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.from_json(json)
|
|
46
|
+
from_hash(JSON.parse(json))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
{
|
|
51
|
+
"scheme" => scheme,
|
|
52
|
+
"network" => network,
|
|
53
|
+
"maxAmountRequired" => max_amount_required,
|
|
54
|
+
"resource" => resource,
|
|
55
|
+
"description" => description,
|
|
56
|
+
"mimeType" => mime_type,
|
|
57
|
+
"outputSchema" => output_schema,
|
|
58
|
+
"payTo" => pay_to,
|
|
59
|
+
"maxTimeoutSeconds" => max_timeout_seconds,
|
|
60
|
+
"asset" => asset,
|
|
61
|
+
"extra" => extra
|
|
62
|
+
}.reject { |_, v| v.nil? }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_json(*args)
|
|
66
|
+
JSON.generate(to_h, *args)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def evm?
|
|
70
|
+
network.include?("base")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def svm?
|
|
74
|
+
network.start_with?("solana")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def stringify_keys(obj)
|
|
81
|
+
case obj
|
|
82
|
+
when Hash
|
|
83
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify_keys(v) }
|
|
84
|
+
when Array
|
|
85
|
+
obj.map { |v| stringify_keys(v) }
|
|
86
|
+
else
|
|
87
|
+
obj
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def validate!
|
|
95
|
+
required = {
|
|
96
|
+
"scheme" => scheme,
|
|
97
|
+
"network" => network,
|
|
98
|
+
"maxAmountRequired" => max_amount_required,
|
|
99
|
+
"resource" => resource,
|
|
100
|
+
"description" => description,
|
|
101
|
+
"payTo" => pay_to,
|
|
102
|
+
"maxTimeoutSeconds" => max_timeout_seconds,
|
|
103
|
+
"asset" => asset
|
|
104
|
+
}
|
|
105
|
+
missing = required.select { |_, v| v.nil? || v.to_s.strip.empty? }.keys
|
|
106
|
+
unless missing.empty?
|
|
107
|
+
raise X402::Errors::ValidationError, "Missing PaymentRequirements fields: #{missing.join(", ")}"
|
|
108
|
+
end
|
|
109
|
+
unless X402::Types::Networks::ALL.include?(network)
|
|
110
|
+
raise X402::Errors::ValidationError, "Unsupported network: #{network}"
|
|
111
|
+
end
|
|
112
|
+
unless scheme == "exact"
|
|
113
|
+
raise X402::Errors::ValidationError, "Unsupported scheme: #{scheme}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "payment_requirements"
|
|
3
|
+
|
|
4
|
+
module X402
|
|
5
|
+
module Types
|
|
6
|
+
class VerifyResponse
|
|
7
|
+
attr_reader :is_valid, :invalid_reason, :payer
|
|
8
|
+
|
|
9
|
+
def initialize(is_valid:, invalid_reason: nil, payer: nil)
|
|
10
|
+
@is_valid = !!is_valid
|
|
11
|
+
@invalid_reason = invalid_reason
|
|
12
|
+
@payer = payer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_hash(hash)
|
|
16
|
+
h = stringify_keys(hash)
|
|
17
|
+
new(
|
|
18
|
+
is_valid: h["isValid"] || h["is_valid"],
|
|
19
|
+
invalid_reason: h["invalidReason"] || h["invalid_reason"],
|
|
20
|
+
payer: h["payer"]
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
"isValid" => is_valid,
|
|
27
|
+
"invalidReason" => invalid_reason,
|
|
28
|
+
"payer" => payer
|
|
29
|
+
}.reject { |_, v| v.nil? }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_json(*args)
|
|
33
|
+
JSON.generate(to_h, *args)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def stringify_keys(obj)
|
|
40
|
+
case obj
|
|
41
|
+
when Hash
|
|
42
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify_keys(v) }
|
|
43
|
+
when Array
|
|
44
|
+
obj.map { |v| stringify_keys(v) }
|
|
45
|
+
else
|
|
46
|
+
obj
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class SettleResponse
|
|
53
|
+
attr_reader :success, :error_reason, :transaction, :network, :payer
|
|
54
|
+
|
|
55
|
+
def initialize(success:, error_reason: nil, transaction: nil, network: nil, payer: nil)
|
|
56
|
+
@success = !!success
|
|
57
|
+
@error_reason = error_reason
|
|
58
|
+
@transaction = transaction
|
|
59
|
+
@network = network
|
|
60
|
+
@payer = payer
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.from_hash(hash)
|
|
64
|
+
h = stringify_keys(hash)
|
|
65
|
+
new(
|
|
66
|
+
success: h["success"],
|
|
67
|
+
error_reason: h["errorReason"] || h["error_reason"],
|
|
68
|
+
transaction: h["transaction"],
|
|
69
|
+
network: h["network"],
|
|
70
|
+
payer: h["payer"]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
"success" => success,
|
|
77
|
+
"errorReason" => error_reason,
|
|
78
|
+
"transaction" => transaction,
|
|
79
|
+
"network" => network,
|
|
80
|
+
"payer" => payer
|
|
81
|
+
}.reject { |_, v| v.nil? }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_json(*args)
|
|
85
|
+
JSON.generate(to_h, *args)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def stringify_keys(obj)
|
|
92
|
+
case obj
|
|
93
|
+
when Hash
|
|
94
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify_keys(v) }
|
|
95
|
+
when Array
|
|
96
|
+
obj.map { |v| stringify_keys(v) }
|
|
97
|
+
else
|
|
98
|
+
obj
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class PaymentRequiredResponse
|
|
105
|
+
attr_reader :x402_version, :accepts, :error
|
|
106
|
+
|
|
107
|
+
def initialize(x402_version:, accepts:, error:)
|
|
108
|
+
@x402_version = Integer(x402_version)
|
|
109
|
+
@accepts = accepts
|
|
110
|
+
@error = error
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.from_hash(hash)
|
|
114
|
+
h = stringify_keys(hash)
|
|
115
|
+
accepts = (h["accepts"] || []).map { |pr| PaymentRequirements.from_hash(pr) }
|
|
116
|
+
new(
|
|
117
|
+
x402_version: h["x402Version"] || h["x402_version"],
|
|
118
|
+
accepts: accepts,
|
|
119
|
+
error: h["error"]
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_h
|
|
124
|
+
{
|
|
125
|
+
"x402Version" => x402_version,
|
|
126
|
+
"accepts" => accepts.map(&:to_h),
|
|
127
|
+
"error" => error
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_json(*args)
|
|
132
|
+
JSON.generate(to_h, *args)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class << self
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def stringify_keys(obj)
|
|
139
|
+
case obj
|
|
140
|
+
when Hash
|
|
141
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify_keys(v) }
|
|
142
|
+
when Array
|
|
143
|
+
obj.map { |v| stringify_keys(v) }
|
|
144
|
+
else
|
|
145
|
+
obj
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
|
data/lib/x402/version.rb
ADDED
data/lib/x402.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative "x402/version"
|
|
2
|
+
require_relative "x402/errors"
|
|
3
|
+
require_relative "x402/config"
|
|
4
|
+
|
|
5
|
+
module X402
|
|
6
|
+
class << self
|
|
7
|
+
def configure
|
|
8
|
+
@config ||= X402::Config.new
|
|
9
|
+
yield @config if block_given?
|
|
10
|
+
@config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def config
|
|
14
|
+
@config ||= X402::Config.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reset_config!
|
|
18
|
+
@config = X402::Config.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
|