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.
@@ -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
+
@@ -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,19 @@
1
+ module X402
2
+ module Types
3
+ module Networks
4
+ EVM = [
5
+ "base",
6
+ "base-sepolia"
7
+ ].freeze
8
+
9
+ SVM = [
10
+ "solana",
11
+ "solana-devnet"
12
+ ].freeze
13
+
14
+ ALL = (EVM + SVM).freeze
15
+ end
16
+ end
17
+ end
18
+
19
+
@@ -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
+
@@ -0,0 +1,5 @@
1
+ module X402
2
+ VERSION = "0.1.0"
3
+ end
4
+
5
+
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
+