x402-rails 0.2.1 → 1.0.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +166 -29
- data/lib/x402/chains.rb +108 -16
- data/lib/x402/configuration.rb +62 -4
- data/lib/x402/payment_payload.rb +82 -10
- data/lib/x402/payment_requirement.rb +34 -13
- data/lib/x402/payment_validator.rb +18 -9
- data/lib/x402/rails/controller_extensions.rb +151 -133
- data/lib/x402/rails/generators/templates/x402_initializer.rb +2 -13
- data/lib/x402/rails/version.rb +1 -1
- data/lib/x402/rails.rb +1 -0
- data/lib/x402/requirement_generator.rb +74 -31
- data/lib/x402/versions/base.rb +39 -0
- data/lib/x402/versions/v1.rb +54 -0
- data/lib/x402/versions/v2.rb +61 -0
- data/lib/x402/versions.rb +17 -0
- metadata +8 -3
data/lib/x402/payment_payload.rb
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module X402
|
|
4
4
|
class PaymentPayload
|
|
5
|
-
attr_accessor :x402_version, :scheme, :network, :payload
|
|
5
|
+
attr_accessor :x402_version, :scheme, :network, :payload, :accepted, :resource_info
|
|
6
6
|
|
|
7
7
|
def initialize(attributes = {})
|
|
8
8
|
attrs = attributes.with_indifferent_access
|
|
9
9
|
|
|
10
|
-
@x402_version = attrs[:x402Version] || attrs[:x402_version] || 1
|
|
11
|
-
@
|
|
12
|
-
@
|
|
10
|
+
@x402_version = (attrs[:x402Version] || attrs[:x402_version] || 1).to_i
|
|
11
|
+
@accepted = attrs[:accepted]
|
|
12
|
+
@resource_info = attrs[:resource]
|
|
13
|
+
|
|
14
|
+
if @accepted
|
|
15
|
+
@scheme = @accepted.with_indifferent_access[:scheme]
|
|
16
|
+
@network = normalize_network(@accepted.with_indifferent_access[:network])
|
|
17
|
+
else
|
|
18
|
+
@scheme = attrs[:scheme]
|
|
19
|
+
@network = normalize_network(attrs[:network])
|
|
20
|
+
end
|
|
21
|
+
|
|
13
22
|
@payload = attrs[:payload]
|
|
14
23
|
end
|
|
15
24
|
|
|
@@ -25,49 +34,112 @@ module X402
|
|
|
25
34
|
end
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
def solana_chain?
|
|
38
|
+
X402.solana_chain?(network)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def evm_chain?
|
|
42
|
+
!solana_chain?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def transaction
|
|
46
|
+
return nil unless solana_chain?
|
|
47
|
+
payload&.with_indifferent_access&.[](:transaction)
|
|
48
|
+
end
|
|
49
|
+
|
|
28
50
|
def authorization
|
|
51
|
+
return nil if solana_chain?
|
|
29
52
|
@authorization ||= payload&.with_indifferent_access&.[](:authorization)
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
def signature
|
|
56
|
+
return nil if solana_chain?
|
|
33
57
|
payload&.with_indifferent_access&.[](:signature)
|
|
34
58
|
end
|
|
35
59
|
|
|
36
60
|
def from_address
|
|
61
|
+
return nil if solana_chain?
|
|
37
62
|
authorization&.with_indifferent_access&.[](:from)
|
|
38
63
|
end
|
|
39
64
|
|
|
40
65
|
def to_address
|
|
66
|
+
return nil if solana_chain?
|
|
41
67
|
authorization&.with_indifferent_access&.[](:to)
|
|
42
68
|
end
|
|
43
69
|
|
|
44
70
|
def value
|
|
71
|
+
return nil if solana_chain?
|
|
45
72
|
authorization&.with_indifferent_access&.[](:value)
|
|
46
73
|
end
|
|
47
74
|
|
|
48
75
|
def valid_after
|
|
76
|
+
return nil if solana_chain?
|
|
49
77
|
authorization&.with_indifferent_access&.[](:validAfter) || authorization&.with_indifferent_access&.[](:valid_after)
|
|
50
78
|
end
|
|
51
79
|
|
|
52
80
|
def valid_before
|
|
81
|
+
return nil if solana_chain?
|
|
53
82
|
authorization&.with_indifferent_access&.[](:validBefore) || authorization&.with_indifferent_access&.[](:valid_before)
|
|
54
83
|
end
|
|
55
84
|
|
|
56
85
|
def nonce
|
|
86
|
+
return nil if solana_chain?
|
|
57
87
|
authorization&.with_indifferent_access&.[](:nonce)
|
|
58
88
|
end
|
|
59
89
|
|
|
60
90
|
def to_h
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
version_strategy = X402::Versions.for(x402_version)
|
|
92
|
+
|
|
93
|
+
if x402_version >= 2
|
|
94
|
+
base = {
|
|
95
|
+
x402Version: x402_version,
|
|
96
|
+
accepted: format_accepted_for_version(version_strategy),
|
|
97
|
+
payload: payload,
|
|
98
|
+
extensions: {}
|
|
99
|
+
}
|
|
100
|
+
base[:resource] = resource_info if resource_info
|
|
101
|
+
base
|
|
102
|
+
else
|
|
103
|
+
{
|
|
104
|
+
x402Version: x402_version,
|
|
105
|
+
scheme: scheme,
|
|
106
|
+
network: version_strategy.format_network(network),
|
|
107
|
+
payload: payload
|
|
108
|
+
}
|
|
109
|
+
end
|
|
67
110
|
end
|
|
68
111
|
|
|
69
112
|
def to_json(*args)
|
|
70
113
|
to_h.to_json(*args)
|
|
71
114
|
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def normalize_network(network_value)
|
|
119
|
+
return network_value unless network_value
|
|
120
|
+
|
|
121
|
+
if network_value.to_s.include?(":")
|
|
122
|
+
X402.from_caip2(network_value)
|
|
123
|
+
else
|
|
124
|
+
network_value
|
|
125
|
+
end
|
|
126
|
+
rescue X402::ConfigurationError
|
|
127
|
+
network_value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def format_accepted_for_version(version_strategy)
|
|
131
|
+
return nil unless accepted
|
|
132
|
+
|
|
133
|
+
acc = accepted.with_indifferent_access
|
|
134
|
+
{
|
|
135
|
+
scheme: acc[:scheme],
|
|
136
|
+
network: version_strategy.format_network(network),
|
|
137
|
+
amount: (acc[:amount] || acc[:maxAmountRequired]).to_s,
|
|
138
|
+
asset: acc[:asset],
|
|
139
|
+
payTo: acc[:payTo] || acc[:pay_to],
|
|
140
|
+
maxTimeoutSeconds: acc[:maxTimeoutSeconds] || acc[:max_timeout_seconds],
|
|
141
|
+
extra: acc[:extra]
|
|
142
|
+
}.compact
|
|
143
|
+
end
|
|
72
144
|
end
|
|
73
145
|
end
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module X402
|
|
4
4
|
class PaymentRequirement
|
|
5
|
-
attr_accessor :scheme, :network, :
|
|
5
|
+
attr_accessor :scheme, :network, :amount, :asset, :pay_to, :resource, :description,
|
|
6
|
+
:max_timeout_seconds, :mime_type, :output_schema, :extra, :version
|
|
6
7
|
|
|
7
8
|
def initialize(attributes = {})
|
|
8
9
|
attrs = attributes.with_indifferent_access
|
|
9
10
|
|
|
10
11
|
@scheme = attrs[:scheme] || "exact"
|
|
11
|
-
@network = attrs[:network]
|
|
12
|
-
@
|
|
12
|
+
@network = normalize_network(attrs[:network])
|
|
13
|
+
@amount = attrs[:maxAmountRequired] || attrs[:max_amount_required] || attrs[:amount]
|
|
13
14
|
@asset = attrs[:asset]
|
|
14
15
|
@pay_to = attrs[:payTo] || attrs[:pay_to]
|
|
15
16
|
@resource = attrs[:resource]
|
|
@@ -18,27 +19,47 @@ module X402
|
|
|
18
19
|
@mime_type = attrs[:mimeType] || attrs[:mime_type] || "application/json"
|
|
19
20
|
@output_schema = attrs[:outputSchema] || attrs[:output_schema]
|
|
20
21
|
@extra = attrs[:extra]
|
|
22
|
+
@version = attrs[:version]
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
+
def max_amount_required
|
|
26
|
+
@amount
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h(version: nil)
|
|
30
|
+
v = version || @version || X402.configuration.version
|
|
31
|
+
version_strategy = X402::Versions.for(v)
|
|
32
|
+
|
|
33
|
+
version_strategy.format_requirement(
|
|
25
34
|
scheme: scheme,
|
|
26
35
|
network: network,
|
|
27
|
-
|
|
36
|
+
amount: amount,
|
|
28
37
|
asset: asset,
|
|
29
|
-
|
|
38
|
+
pay_to: pay_to,
|
|
30
39
|
resource: resource,
|
|
31
40
|
description: description,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
h[:extra] = extra if extra
|
|
37
|
-
h
|
|
41
|
+
max_timeout_seconds: max_timeout_seconds,
|
|
42
|
+
mime_type: mime_type,
|
|
43
|
+
extra: extra
|
|
44
|
+
)
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
def to_json(*args)
|
|
41
48
|
to_h.to_json(*args)
|
|
42
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def normalize_network(network_value)
|
|
54
|
+
return network_value unless network_value
|
|
55
|
+
|
|
56
|
+
if network_value.to_s.include?(":")
|
|
57
|
+
X402.from_caip2(network_value)
|
|
58
|
+
else
|
|
59
|
+
network_value
|
|
60
|
+
end
|
|
61
|
+
rescue X402::ConfigurationError
|
|
62
|
+
network_value
|
|
63
|
+
end
|
|
43
64
|
end
|
|
44
65
|
end
|
|
@@ -19,17 +19,26 @@ module X402
|
|
|
19
19
|
return validation_error("Network mismatch: expected #{requirement.network}, got #{payment_payload.network}")
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
# For EVM chains, validate recipient and amount locally before calling facilitator
|
|
23
|
+
# For Solana chains, the facilitator handles all validation of the transaction
|
|
24
|
+
if payment_payload.evm_chain?
|
|
25
|
+
# Validate recipient address
|
|
26
|
+
unless payment_payload.to_address&.downcase == requirement.pay_to&.downcase
|
|
27
|
+
return validation_error("Recipient mismatch: expected #{requirement.pay_to}, got #{payment_payload.to_address}")
|
|
28
|
+
end
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
# Validate amount
|
|
31
|
+
payment_value = payment_payload.value.to_i
|
|
32
|
+
required_value = requirement.max_amount_required.to_i
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
if payment_value < required_value
|
|
35
|
+
return validation_error("Insufficient amount: expected at least #{required_value}, got #{payment_value}")
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
# Solana: verify transaction payload exists
|
|
39
|
+
unless payment_payload.transaction
|
|
40
|
+
return validation_error("Solana payment missing transaction payload")
|
|
41
|
+
end
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
# Call facilitator to verify payment (does NOT settle on blockchain yet)
|
|
@@ -11,110 +11,215 @@ module X402
|
|
|
11
11
|
|
|
12
12
|
def x402_paywall(options = {})
|
|
13
13
|
amount = options[:amount] or raise ArgumentError, "amount is required"
|
|
14
|
-
chain = options[:chain]
|
|
15
|
-
currency = options[:currency]
|
|
14
|
+
chain = options[:chain]
|
|
15
|
+
currency = options[:currency]
|
|
16
|
+
protocol_version = options[:version] || X402.configuration.version
|
|
17
|
+
wallet_address = options[:wallet_address]
|
|
18
|
+
fee_payer = options[:fee_payer]
|
|
19
|
+
accepts = options[:accepts]
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
begin
|
|
22
|
+
version_strategy = X402::Versions.for(protocol_version)
|
|
23
|
+
rescue X402::ConfigurationError => e
|
|
24
|
+
return render_configuration_error(e.message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
payment_header = request.headers[version_strategy.payment_header_name]
|
|
19
28
|
|
|
20
29
|
if payment_header.nil? || payment_header.empty?
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
return render_payment_required(
|
|
31
|
+
amount,
|
|
32
|
+
chain: chain,
|
|
33
|
+
currency: currency,
|
|
34
|
+
version: protocol_version,
|
|
35
|
+
wallet_address: wallet_address,
|
|
36
|
+
fee_payer: fee_payer,
|
|
37
|
+
accepts: accepts
|
|
38
|
+
)
|
|
23
39
|
end
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
process_payment(
|
|
42
|
+
payment_header, amount,
|
|
43
|
+
chain: chain,
|
|
44
|
+
currency: currency,
|
|
45
|
+
version: protocol_version,
|
|
46
|
+
wallet_address: wallet_address,
|
|
47
|
+
fee_payer: fee_payer,
|
|
48
|
+
accepts: accepts
|
|
49
|
+
)
|
|
27
50
|
end
|
|
28
51
|
|
|
29
52
|
private
|
|
30
53
|
|
|
31
|
-
def generate_payment_required_response(amount,
|
|
54
|
+
def generate_payment_required_response(amount, error_message = nil,
|
|
55
|
+
chain: nil, currency: nil, version: nil,
|
|
56
|
+
wallet_address: nil, fee_payer: nil, accepts: nil)
|
|
57
|
+
protocol_version = version || X402.configuration.version
|
|
58
|
+
|
|
32
59
|
requirement_response = X402::RequirementGenerator.generate(
|
|
33
60
|
amount: amount,
|
|
34
61
|
resource: request.original_url,
|
|
35
62
|
description: "Payment required for #{request.path}",
|
|
36
63
|
chain: chain,
|
|
37
|
-
currency: currency
|
|
64
|
+
currency: currency,
|
|
65
|
+
version: protocol_version,
|
|
66
|
+
wallet_address: wallet_address,
|
|
67
|
+
fee_payer: fee_payer,
|
|
68
|
+
accepts: accepts
|
|
38
69
|
)
|
|
39
70
|
requirement_response[:error] = error_message if error_message
|
|
40
71
|
requirement_response
|
|
41
72
|
end
|
|
42
73
|
|
|
43
|
-
def render_payment_required(amount, chain, currency
|
|
44
|
-
|
|
74
|
+
def render_payment_required(amount, chain: nil, currency: nil, version: nil,
|
|
75
|
+
wallet_address: nil, fee_payer: nil, accepts: nil)
|
|
76
|
+
protocol_version = version || X402.configuration.version
|
|
77
|
+
version_strategy = X402::Versions.for(protocol_version)
|
|
78
|
+
|
|
79
|
+
requirement_response = generate_payment_required_response(
|
|
80
|
+
amount,
|
|
81
|
+
chain: chain,
|
|
82
|
+
currency: currency,
|
|
83
|
+
version: protocol_version,
|
|
84
|
+
wallet_address: wallet_address,
|
|
85
|
+
fee_payer: fee_payer,
|
|
86
|
+
accepts: accepts
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
render_402_response(requirement_response, version_strategy)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_402_response(requirement_response, version_strategy)
|
|
93
|
+
if version_strategy.requirement_header_name
|
|
94
|
+
response.headers[version_strategy.requirement_header_name] =
|
|
95
|
+
Base64.strict_encode64(requirement_response.to_json)
|
|
96
|
+
end
|
|
97
|
+
render json: requirement_response, status: :payment_required
|
|
98
|
+
end
|
|
45
99
|
|
|
46
|
-
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
100
|
+
def render_configuration_error(message)
|
|
101
|
+
# Use V1 as a safe fallback when version is invalid to avoid recursive errors
|
|
102
|
+
fallback_strategy = X402::Versions::V1.new
|
|
103
|
+
error_response = {
|
|
104
|
+
x402Version: 1,
|
|
105
|
+
error: "Configuration error: #{message}",
|
|
106
|
+
accepts: []
|
|
107
|
+
}
|
|
108
|
+
render_402_response(error_response, fallback_strategy)
|
|
52
109
|
end
|
|
53
110
|
|
|
54
|
-
def process_payment(payment_header, amount, chain, currency
|
|
55
|
-
|
|
111
|
+
def process_payment(payment_header, amount, chain: nil, currency: nil,
|
|
112
|
+
version: nil, wallet_address: nil, fee_payer: nil, accepts: nil)
|
|
113
|
+
protocol_version = version || X402.configuration.version
|
|
114
|
+
version_strategy = X402::Versions.for(protocol_version)
|
|
115
|
+
|
|
56
116
|
payment_payload = X402::PaymentPayload.from_header(payment_header)
|
|
57
117
|
|
|
58
|
-
# Generate requirement for validation (must match the 402 response exactly!)
|
|
59
118
|
requirement_data = X402::RequirementGenerator.generate(
|
|
60
119
|
amount: amount,
|
|
61
120
|
resource: request.original_url,
|
|
62
121
|
description: "Payment required for #{request.path}",
|
|
63
122
|
chain: chain,
|
|
64
|
-
currency: currency
|
|
123
|
+
currency: currency,
|
|
124
|
+
version: protocol_version,
|
|
125
|
+
wallet_address: wallet_address,
|
|
126
|
+
fee_payer: fee_payer,
|
|
127
|
+
accepts: accepts
|
|
65
128
|
)
|
|
66
129
|
|
|
67
|
-
|
|
130
|
+
matching_accept = find_matching_accept(requirement_data[:accepts], payment_payload, version_strategy)
|
|
131
|
+
|
|
132
|
+
unless matching_accept
|
|
133
|
+
requirement_response = generate_payment_required_response(
|
|
134
|
+
amount, "Payment network not accepted: #{payment_payload.network}",
|
|
135
|
+
chain: chain, currency: currency, version: protocol_version,
|
|
136
|
+
wallet_address: wallet_address, fee_payer: fee_payer, accepts: accepts
|
|
137
|
+
)
|
|
138
|
+
return render_402_response(requirement_response, version_strategy)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
resource_info = requirement_data[:resource] || {}
|
|
142
|
+
additional_attrs = { version: protocol_version }
|
|
143
|
+
additional_attrs[:resource] = resource_info[:url] if resource_info[:url]
|
|
144
|
+
additional_attrs[:description] = resource_info[:description] if resource_info[:description]
|
|
145
|
+
additional_attrs[:mime_type] = resource_info[:mimeType] if resource_info[:mimeType]
|
|
146
|
+
requirement_attrs = matching_accept.merge(additional_attrs)
|
|
147
|
+
requirement = X402::PaymentRequirement.new(requirement_attrs)
|
|
68
148
|
|
|
69
|
-
# Validate payment (verify signature, but don't settle on blockchain yet)
|
|
70
149
|
validator = X402::PaymentValidator.new
|
|
71
150
|
validation_result = validator.validate(payment_payload, requirement)
|
|
72
151
|
|
|
73
152
|
unless validation_result[:valid]
|
|
74
|
-
requirement_response = generate_payment_required_response(
|
|
75
|
-
|
|
153
|
+
requirement_response = generate_payment_required_response(
|
|
154
|
+
amount, validation_result[:error],
|
|
155
|
+
chain: chain, currency: currency, version: protocol_version,
|
|
156
|
+
wallet_address: wallet_address, fee_payer: fee_payer, accepts: accepts
|
|
157
|
+
)
|
|
158
|
+
return render_402_response(requirement_response, version_strategy)
|
|
76
159
|
end
|
|
77
160
|
|
|
78
|
-
# Store payment info and requirement in request environment
|
|
79
161
|
request.env["x402.payment"] = {
|
|
80
162
|
payer: validation_result[:payer],
|
|
81
163
|
amount: payment_payload.value,
|
|
82
164
|
network: payment_payload.network,
|
|
83
165
|
payload: payment_payload,
|
|
84
|
-
requirement: requirement
|
|
166
|
+
requirement: requirement,
|
|
167
|
+
version: protocol_version
|
|
85
168
|
}
|
|
86
169
|
|
|
87
|
-
# If non-optimistic mode, settle payment synchronously before continuing
|
|
88
170
|
unless X402.configuration.optimistic
|
|
89
171
|
settlement_result = settle_payment_now
|
|
90
172
|
|
|
91
|
-
# If settlement failed, abort and return 402 with payment requirements
|
|
92
173
|
if settlement_result.nil? || !settlement_result.success?
|
|
93
174
|
error_message = settlement_result&.error_reason || "Settlement failed"
|
|
94
|
-
requirement_response = generate_payment_required_response(
|
|
95
|
-
|
|
175
|
+
requirement_response = generate_payment_required_response(
|
|
176
|
+
amount, "failed to settle payment: #{error_message}",
|
|
177
|
+
chain: chain, currency: currency, version: protocol_version,
|
|
178
|
+
wallet_address: wallet_address, fee_payer: fee_payer, accepts: accepts
|
|
179
|
+
)
|
|
180
|
+
return render_402_response(requirement_response, version_strategy)
|
|
96
181
|
end
|
|
97
182
|
end
|
|
98
183
|
|
|
99
|
-
# Payment verified, continue with action
|
|
100
|
-
# In optimistic mode, settlement will happen automatically via after_action callback
|
|
101
184
|
rescue X402::InvalidPaymentError => e
|
|
102
|
-
requirement_response = generate_payment_required_response(
|
|
103
|
-
|
|
185
|
+
requirement_response = generate_payment_required_response(
|
|
186
|
+
amount, "Invalid payment: #{e.message}",
|
|
187
|
+
chain: chain, currency: currency, version: protocol_version,
|
|
188
|
+
wallet_address: wallet_address, fee_payer: fee_payer, accepts: accepts
|
|
189
|
+
)
|
|
190
|
+
render_402_response(requirement_response, version_strategy)
|
|
104
191
|
rescue X402::FacilitatorError => e
|
|
105
|
-
requirement_response = generate_payment_required_response(
|
|
106
|
-
|
|
192
|
+
requirement_response = generate_payment_required_response(
|
|
193
|
+
amount, "Verification error: #{e.message}",
|
|
194
|
+
chain: chain, currency: currency, version: protocol_version,
|
|
195
|
+
wallet_address: wallet_address, fee_payer: fee_payer, accepts: accepts
|
|
196
|
+
)
|
|
197
|
+
render_402_response(requirement_response, version_strategy)
|
|
198
|
+
rescue X402::ConfigurationError => e
|
|
199
|
+
render_configuration_error(e.message)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def find_matching_accept(accepts, payment_payload, version_strategy)
|
|
203
|
+
payment_network = payment_payload.network
|
|
204
|
+
|
|
205
|
+
accepts.find do |accept|
|
|
206
|
+
accept_network = accept[:network]
|
|
207
|
+
next false unless accept_network.present?
|
|
208
|
+
|
|
209
|
+
if accept_network.to_s.include?(":")
|
|
210
|
+
version_strategy.parse_network(accept_network) == payment_network
|
|
211
|
+
else
|
|
212
|
+
accept_network == payment_network
|
|
213
|
+
end
|
|
214
|
+
end
|
|
107
215
|
end
|
|
108
216
|
|
|
109
217
|
def settle_x402_payment_if_needed
|
|
110
|
-
# Only run in optimistic mode (non-optimistic settles synchronously)
|
|
111
218
|
return unless X402.configuration.optimistic
|
|
112
219
|
|
|
113
|
-
# Only settle if payment was verified
|
|
114
220
|
payment_info = request.env["x402.payment"]
|
|
115
221
|
return unless payment_info
|
|
116
222
|
|
|
117
|
-
# Only settle if response is 2xx (success)
|
|
118
223
|
return unless response.status >= 200 && response.status < 300
|
|
119
224
|
|
|
120
225
|
perform_settlement(payment_info)
|
|
@@ -127,7 +232,6 @@ module X402
|
|
|
127
232
|
::Rails.logger.info("=== X402 Non-Optimistic Settlement (before response) ===")
|
|
128
233
|
settlement_result = perform_settlement(payment_info)
|
|
129
234
|
|
|
130
|
-
# Store settlement result for later use (e.g., adding to response body)
|
|
131
235
|
request.env["x402.settlement_result"] = settlement_result
|
|
132
236
|
settlement_result
|
|
133
237
|
end
|
|
@@ -141,19 +245,19 @@ module X402
|
|
|
141
245
|
::Rails.logger.info("Requirement class: #{payment_info[:requirement].class}")
|
|
142
246
|
::Rails.logger.info("Requirement hash: #{payment_info[:requirement].to_h.inspect}")
|
|
143
247
|
|
|
248
|
+
protocol_version = payment_info[:version] || X402.configuration.version
|
|
249
|
+
version_strategy = X402::Versions.for(protocol_version)
|
|
250
|
+
|
|
144
251
|
facilitator_client = X402::FacilitatorClient.new
|
|
145
252
|
settlement_result = facilitator_client.settle(
|
|
146
253
|
payment_info[:payload],
|
|
147
|
-
payment_info[:requirement].to_h
|
|
254
|
+
payment_info[:requirement].to_h(version: protocol_version)
|
|
148
255
|
)
|
|
149
256
|
|
|
150
257
|
if settlement_result.success?
|
|
151
|
-
|
|
152
|
-
response.headers["X-PAYMENT-RESPONSE"] = settlement_result.to_base64
|
|
258
|
+
response.headers[version_strategy.response_header_name] = settlement_result.to_base64
|
|
153
259
|
::Rails.logger.info("x402 settlement successful: #{settlement_result.transaction}")
|
|
154
260
|
else
|
|
155
|
-
# Settlement failed - in optimistic mode, user already got the service
|
|
156
|
-
# In non-optimistic mode, this will be caught before the response is sent
|
|
157
261
|
::Rails.logger.error("x402 settlement failed: #{settlement_result.error_reason}")
|
|
158
262
|
end
|
|
159
263
|
|
|
@@ -164,92 +268,6 @@ module X402
|
|
|
164
268
|
end
|
|
165
269
|
end
|
|
166
270
|
|
|
167
|
-
def browser_request?
|
|
168
|
-
# If Accept header explicitly requests JSON, return JSON even from browsers
|
|
169
|
-
accept_header = request.headers["Accept"].to_s
|
|
170
|
-
return false if accept_header.include?("application/json")
|
|
171
|
-
|
|
172
|
-
# Otherwise, check User-Agent for browser indicators
|
|
173
|
-
user_agent = request.headers["User-Agent"].to_s
|
|
174
|
-
user_agent.match?(/(Mozilla|Chrome|Safari|Firefox|Edge|Opera)/i)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def render_html_paywall(requirement_response)
|
|
178
|
-
html = <<~HTML
|
|
179
|
-
<!DOCTYPE html>
|
|
180
|
-
<html>
|
|
181
|
-
<head>
|
|
182
|
-
<title>Payment Required</title>
|
|
183
|
-
<style>
|
|
184
|
-
body {
|
|
185
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
186
|
-
display: flex;
|
|
187
|
-
justify-content: center;
|
|
188
|
-
align-items: center;
|
|
189
|
-
min-height: 100vh;
|
|
190
|
-
margin: 0;
|
|
191
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
192
|
-
}
|
|
193
|
-
.paywall-container {
|
|
194
|
-
background: white;
|
|
195
|
-
padding: 3rem;
|
|
196
|
-
border-radius: 1rem;
|
|
197
|
-
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
198
|
-
max-width: 500px;
|
|
199
|
-
text-align: center;
|
|
200
|
-
}
|
|
201
|
-
h1 {
|
|
202
|
-
color: #333;
|
|
203
|
-
margin-bottom: 1rem;
|
|
204
|
-
font-size: 2rem;
|
|
205
|
-
}
|
|
206
|
-
p {
|
|
207
|
-
color: #666;
|
|
208
|
-
line-height: 1.6;
|
|
209
|
-
margin-bottom: 2rem;
|
|
210
|
-
}
|
|
211
|
-
.amount {
|
|
212
|
-
font-size: 2.5rem;
|
|
213
|
-
font-weight: bold;
|
|
214
|
-
color: #667eea;
|
|
215
|
-
margin: 1.5rem 0;
|
|
216
|
-
}
|
|
217
|
-
.info {
|
|
218
|
-
background: #f7f7f7;
|
|
219
|
-
padding: 1rem;
|
|
220
|
-
border-radius: 0.5rem;
|
|
221
|
-
margin: 1.5rem 0;
|
|
222
|
-
font-size: 0.9rem;
|
|
223
|
-
}
|
|
224
|
-
code {
|
|
225
|
-
background: #e0e0e0;
|
|
226
|
-
padding: 0.2rem 0.5rem;
|
|
227
|
-
border-radius: 0.25rem;
|
|
228
|
-
font-family: monospace;
|
|
229
|
-
}
|
|
230
|
-
</style>
|
|
231
|
-
</head>
|
|
232
|
-
<body>
|
|
233
|
-
<div class="paywall-container">
|
|
234
|
-
<h1>💳 Payment Required</h1>
|
|
235
|
-
<p>This resource requires payment to access.</p>
|
|
236
|
-
<div class="amount">$#{format('%.3f', requirement_response[:accepts].first[:maxAmountRequired].to_i / 1_000_000.0)}</div>
|
|
237
|
-
<div class="info">
|
|
238
|
-
<p><strong>Network:</strong> #{requirement_response[:accepts].first[:network]}</p>
|
|
239
|
-
<p><strong>Asset:</strong> USDC</p>
|
|
240
|
-
<p><strong>Resource:</strong> #{requirement_response[:accepts].first[:resource]}</p>
|
|
241
|
-
</div>
|
|
242
|
-
<p style="font-size: 0.85rem; color: #999;">
|
|
243
|
-
This resource uses the x402 payment protocol.
|
|
244
|
-
API clients can make payments programmatically by including the X-PAYMENT header.
|
|
245
|
-
</p>
|
|
246
|
-
</div>
|
|
247
|
-
</body>
|
|
248
|
-
</html>
|
|
249
|
-
HTML
|
|
250
|
-
|
|
251
|
-
render html: html.html_safe, status: :payment_required
|
|
252
|
-
end
|
|
253
271
|
end
|
|
254
272
|
end
|
|
255
273
|
end
|
|
@@ -8,9 +8,9 @@ X402.configure do |config|
|
|
|
8
8
|
# Set this via environment variable: X402_WALLET_ADDRESS
|
|
9
9
|
config.wallet_address = ENV.fetch("X402_WALLET_ADDRESS", nil)
|
|
10
10
|
|
|
11
|
-
# Facilitator URL (default: https://x402.org/facilitator)
|
|
11
|
+
# Facilitator URL (default: https://www.x402.org/facilitator)
|
|
12
12
|
# The facilitator handles payment verification and settlement
|
|
13
|
-
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
|
|
13
|
+
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://www.x402.org/facilitator")
|
|
14
14
|
|
|
15
15
|
# Blockchain network to use
|
|
16
16
|
# Options: "base-sepolia" (testnet), "base" (mainnet), "avalanche-fuji" (testnet), "avalanche" (mainnet)
|
|
@@ -19,17 +19,6 @@ 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
|
-
# Custom RPC URLs (optional)
|
|
24
|
-
# Use custom RPC endpoints from providers like QuickNode, Alchemy, or Infura
|
|
25
|
-
# for better reliability and rate limits. Uncomment and configure as needed:
|
|
26
|
-
#
|
|
27
|
-
# config.rpc_urls["base"] = "https://your-base-rpc.quiknode.pro/your-key"
|
|
28
|
-
# config.rpc_urls["base-sepolia"] = "https://your-sepolia-rpc.quiknode.pro/your-key"
|
|
29
|
-
# config.rpc_urls["avalanche"] = "https://your-avalanche-rpc.quiknode.pro/your-key"
|
|
30
|
-
#
|
|
31
|
-
# Or use environment variables (see README.md for details):
|
|
32
|
-
# X402_BASE_RPC_URL, X402_BASE_SEPOLIA_RPC_URL, etc.
|
|
33
22
|
end
|
|
34
23
|
|
|
35
24
|
# Validate configuration on initialization
|