x402-rails 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/.rspec +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +236 -0
- data/Rakefile +8 -0
- data/coverage/coverage.svg +19 -0
- data/lib/x402/chains.rb +81 -0
- data/lib/x402/configuration.rb +40 -0
- data/lib/x402/facilitator_client.rb +97 -0
- data/lib/x402/payment_payload.rb +73 -0
- data/lib/x402/payment_requirement.rb +44 -0
- data/lib/x402/payment_validator.rb +72 -0
- data/lib/x402/rails/controller_extensions.rb +255 -0
- data/lib/x402/rails/generators/install_generator.rb +32 -0
- data/lib/x402/rails/generators/templates/x402_initializer.rb +25 -0
- data/lib/x402/rails/railtie.rb +17 -0
- data/lib/x402/rails/version.rb +7 -0
- data/lib/x402/rails.rb +25 -0
- data/lib/x402/requirement_generator.rb +55 -0
- data/lib/x402/settlement_response.rb +37 -0
- metadata +178 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 411abe38109c401d64e94078fd5b69bb3937b78e7bb6b1518921897ca28ad904
|
|
4
|
+
data.tar.gz: 2fe5f74f481df1a9b2e74cbd268ad02033d5250f9624eeb7a4912742409a53a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f1bbcaee8f5a30a99fb857f9983bc6ec638d9a6f10c2651e14ab65e3bb68bd4087fac81c5c2db25b1cabd1c92209cb6754a82b369e9f76dbf21998a43cf303c3
|
|
7
|
+
data.tar.gz: ac5aa6785d6cf1a9284cd4ac776200199d7c53696ae462c3775e226218727be30cbc2acabd39329aec6d8ec6918805fd52b609cddd185262d159b46856817da8
|
data/.rspec
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Quicknode, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# x402-rails
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Accept instant blockchain micropayments in your Rails applications using the [x402 payment protocol](https://www.x402.org/).
|
|
6
|
+
|
|
7
|
+
Supports Base, avalanche, and other blockchain networks.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **1 line of code** to accept digital dollars (USDC)
|
|
12
|
+
- **No fees** on supported networks (Base)
|
|
13
|
+
- **~1 second** response times (optimistic mode)
|
|
14
|
+
- **$0.001 minimum** payment amounts
|
|
15
|
+
- **Optimistic & non-optimistic** settlement modes
|
|
16
|
+
- **Automatic settlement** after successful responses
|
|
17
|
+
- **Browser paywall** and API support
|
|
18
|
+
- **Rails 7.0+** compatible
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add to your Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'x402-rails'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Configure the gem
|
|
37
|
+
|
|
38
|
+
Create `config/initializers/x402.rb`:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
X402.configure do |config|
|
|
42
|
+
config.wallet_address = ENV['X402_WALLET_ADDRESS'] # Your recipient wallet
|
|
43
|
+
config.facilitator = "https://x402.org/facilitator"
|
|
44
|
+
config.chain = "base-sepolia" # or "base" for base mainnet
|
|
45
|
+
config.currency = "USDC"
|
|
46
|
+
config.optimistic = false # Forces to check for settlement before giving response.
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Protect your endpoints
|
|
51
|
+
|
|
52
|
+
Use `x402_paywall` in any controller action:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class ApiController < ApplicationController
|
|
56
|
+
def weather
|
|
57
|
+
x402_paywall(amount: 0.001) # $0.001 in USD
|
|
58
|
+
|
|
59
|
+
render json: {
|
|
60
|
+
temperature: 72,
|
|
61
|
+
paid_by: request.env['x402.payment'][:payer]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That's it! Your endpoint now requires payment.
|
|
68
|
+
|
|
69
|
+
## Usage Patterns
|
|
70
|
+
|
|
71
|
+
### Direct Method Call
|
|
72
|
+
|
|
73
|
+
Call `x402_paywall` in any action:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
def show
|
|
77
|
+
x402_paywall(amount: 0.01)
|
|
78
|
+
# Action continues after payment verified
|
|
79
|
+
render json: @data
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Before Action Hook
|
|
84
|
+
|
|
85
|
+
Protect multiple actions:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class PremiumController < ApplicationController
|
|
89
|
+
before_action :require_payment, only: [:show, :index]
|
|
90
|
+
|
|
91
|
+
def show
|
|
92
|
+
# Payment already verified
|
|
93
|
+
render json: @premium_content
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def require_payment
|
|
99
|
+
x402_paywall(amount: 0.001, chain: "base")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Per-Action Pricing
|
|
105
|
+
|
|
106
|
+
Different prices for different actions:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def basic_data
|
|
110
|
+
x402_paywall(amount: 0.001)
|
|
111
|
+
render json: basic_info
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def premium_data
|
|
115
|
+
x402_paywall(amount: 0.01)
|
|
116
|
+
render json: premium_info
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Configuration Options
|
|
121
|
+
|
|
122
|
+
### Global Configuration
|
|
123
|
+
|
|
124
|
+
Set defaults in `config/initializers/x402.rb`:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
X402.configure do |config|
|
|
128
|
+
# Required: Your wallet address where payments will be received
|
|
129
|
+
config.wallet_address = ENV['X402_WALLET_ADDRESS']
|
|
130
|
+
|
|
131
|
+
# Facilitator service URL (default: "https://x402.org/facilitator")
|
|
132
|
+
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
|
|
133
|
+
|
|
134
|
+
# Blockchain network (default: "base-sepolia")
|
|
135
|
+
# Options: "base-sepolia", "base", "avalanche-fuji", "avalanche"
|
|
136
|
+
config.chain = ENV.fetch("X402_CHAIN", "base-sepolia")
|
|
137
|
+
|
|
138
|
+
# Payment token (default: "USDC")
|
|
139
|
+
# Currently only USDC is supported
|
|
140
|
+
config.currency = ENV.fetch("X402_CURRENCY","USDC")
|
|
141
|
+
|
|
142
|
+
# Optimistic mode (default: true)
|
|
143
|
+
# true: Fast response, settle payment after response is sent
|
|
144
|
+
# false: Wait for blockchain settlement before sending response
|
|
145
|
+
config.optimistic = ENV.fetch("X402_OPTIMISTIC",false)
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Configuration Attributes
|
|
150
|
+
|
|
151
|
+
| Attribute | Required | Default | Description |
|
|
152
|
+
| ---------------- | -------- | -------------------------------- | --------------------------------------------------------------------------------- |
|
|
153
|
+
| `wallet_address` | **Yes** | - | Your Ethereum wallet address where payments will be received |
|
|
154
|
+
| `facilitator` | No | `"https://x402.org/facilitator"` | Facilitator service URL for payment verification and settlement |
|
|
155
|
+
| `chain` | No | `"base-sepolia"` | Blockchain network to use (`base-sepolia`, `base`, `avalanche-fuji`, `avalanche`) |
|
|
156
|
+
| `currency` | No | `"USDC"` | Payment token symbol (currently only USDC supported) |
|
|
157
|
+
| `optimistic` | No | `true` | Settlement mode (see Optimistic vs Non-Optimistic Mode below) |
|
|
158
|
+
|
|
159
|
+
## Environment Variables
|
|
160
|
+
|
|
161
|
+
Configure via environment variables:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Required
|
|
165
|
+
X402_WALLET_ADDRESS=0xYourAddress
|
|
166
|
+
|
|
167
|
+
# Optional (with defaults)
|
|
168
|
+
X402_FACILITATOR_URL=https://x402.org/facilitator
|
|
169
|
+
X402_CHAIN=base-sepolia
|
|
170
|
+
X402_CURRENCY=USDC
|
|
171
|
+
X402_OPTIMISTIC=true # "true" or "false"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Examples
|
|
175
|
+
|
|
176
|
+
### Weather API
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
class WeatherController < ApplicationController
|
|
180
|
+
def current
|
|
181
|
+
x402_paywall(amount: 0.001)
|
|
182
|
+
render json: { temp: 72, condition: "sunny" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def forecast
|
|
186
|
+
x402_paywall(amount: 0.01)
|
|
187
|
+
render json: { forecast: [...] }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## x402 Architecture
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
|
196
|
+
│ Client │─────▶│ Rails │─────▶│ Facilitator │
|
|
197
|
+
│ │ │ x402 │ │ (x402.org) │
|
|
198
|
+
└──────────┘ └──────────┘ └─────────────┘
|
|
199
|
+
│ │ │
|
|
200
|
+
│ │ ▼
|
|
201
|
+
│ │ ┌──────────────┐
|
|
202
|
+
│ │ │ Blockchain │
|
|
203
|
+
│ │ │ (Base) │
|
|
204
|
+
└──────────────────┴─────────────┴──────────────┘
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Error Handling
|
|
208
|
+
|
|
209
|
+
The gem raises these errors:
|
|
210
|
+
|
|
211
|
+
- `X402::ConfigurationError` - Invalid configuration
|
|
212
|
+
- `X402::InvalidPaymentError` - Invalid payment payload
|
|
213
|
+
- `X402::FacilitatorError` - Facilitator communication issues
|
|
214
|
+
|
|
215
|
+
## Security
|
|
216
|
+
|
|
217
|
+
- Payments validated via EIP-712 signatures
|
|
218
|
+
- Nonce prevents replay attacks
|
|
219
|
+
- Time windows limit authorization validity
|
|
220
|
+
- Facilitator verifies all parameters
|
|
221
|
+
- Settlement happens on-chain (immutable)
|
|
222
|
+
|
|
223
|
+
## Requirements
|
|
224
|
+
|
|
225
|
+
- Ruby 3.0+
|
|
226
|
+
- Rails 7.0+
|
|
227
|
+
|
|
228
|
+
## Resources
|
|
229
|
+
|
|
230
|
+
- [x402 Protocol Docs](https://docs.cdp.coinbase.com/x402)
|
|
231
|
+
- [GitHub Repository](https://github.com/coinbase/x402)
|
|
232
|
+
- [Facilitator API](https://x402.org/facilitator)
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="94" height="20">
|
|
2
|
+
<linearGradient id="b" x2="0" y2="100%">
|
|
3
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
4
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
5
|
+
</linearGradient>
|
|
6
|
+
<clipPath id="a">
|
|
7
|
+
<rect width="94" height="20" rx="3" fill="#fff"/>
|
|
8
|
+
</clipPath>
|
|
9
|
+
<g clip-path="url(#a)">
|
|
10
|
+
<path fill="#555" d="M0 0h59v20H0z"/>
|
|
11
|
+
<path fill="#4c1" d="M59 0h35v20H59z"/>
|
|
12
|
+
<path fill="url(#b)" d="M0 0h94v20H0z"/>
|
|
13
|
+
</g>
|
|
14
|
+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
|
15
|
+
<text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text>
|
|
16
|
+
<text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text>
|
|
17
|
+
<text x="755" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="250">100%</text>
|
|
18
|
+
<text x="755" y="140" transform="scale(.1)" textLength="250">100%</text></g>
|
|
19
|
+
</svg>
|
data/lib/x402/chains.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
# Chain configurations for supported networks
|
|
5
|
+
CHAINS = {
|
|
6
|
+
"base-sepolia" => {
|
|
7
|
+
chain_id: 84532,
|
|
8
|
+
rpc_url: "https://sepolia.base.org",
|
|
9
|
+
usdc_address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
10
|
+
explorer_url: "https://sepolia.basescan.org"
|
|
11
|
+
},
|
|
12
|
+
"base" => {
|
|
13
|
+
chain_id: 8453,
|
|
14
|
+
rpc_url: "https://mainnet.base.org",
|
|
15
|
+
usdc_address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
16
|
+
explorer_url: "https://basescan.org"
|
|
17
|
+
},
|
|
18
|
+
"avalanche-fuji" => {
|
|
19
|
+
chain_id: 43113,
|
|
20
|
+
rpc_url: "https://api.avax-test.network/ext/bc/C/rpc",
|
|
21
|
+
usdc_address: "0x5425890298aed601595a70AB815c96711a31Bc65",
|
|
22
|
+
explorer_url: "https://testnet.snowtrace.io"
|
|
23
|
+
},
|
|
24
|
+
"avalanche" => {
|
|
25
|
+
chain_id: 43114,
|
|
26
|
+
rpc_url: "https://api.avax.network/ext/bc/C/rpc",
|
|
27
|
+
usdc_address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
|
|
28
|
+
explorer_url: "https://snowtrace.io"
|
|
29
|
+
}
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Currency configurations by chain
|
|
33
|
+
CURRENCY_BY_CHAIN = {
|
|
34
|
+
"base-sepolia" => {
|
|
35
|
+
symbol: "USDC",
|
|
36
|
+
decimals: 6,
|
|
37
|
+
name: "USDC", # Testnet uses "USDC"
|
|
38
|
+
version: "2"
|
|
39
|
+
},
|
|
40
|
+
"base" => {
|
|
41
|
+
symbol: "USDC",
|
|
42
|
+
decimals: 6,
|
|
43
|
+
name: "USD Coin", # Mainnet uses "USD Coin"
|
|
44
|
+
version: "2"
|
|
45
|
+
},
|
|
46
|
+
"avalanche-fuji" => {
|
|
47
|
+
symbol: "USDC",
|
|
48
|
+
decimals: 6,
|
|
49
|
+
name: "USD Coin", # Testnet uses "USD Coin"
|
|
50
|
+
version: "2"
|
|
51
|
+
},
|
|
52
|
+
"avalanche" => {
|
|
53
|
+
symbol: "USDC",
|
|
54
|
+
decimals: 6,
|
|
55
|
+
name: "USDC", # Mainnet uses "USDC"
|
|
56
|
+
version: "2"
|
|
57
|
+
}
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def chain_config(chain_name)
|
|
62
|
+
CHAINS[chain_name] || raise(ConfigurationError, "Unsupported chain: #{chain_name}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def currency_config_for_chain(chain_name)
|
|
66
|
+
CURRENCY_BY_CHAIN[chain_name] || raise(ConfigurationError, "Unsupported chain for currency: #{chain_name}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def supported_chains
|
|
70
|
+
CHAINS.keys
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def usdc_address_for(chain_name)
|
|
74
|
+
chain_config(chain_name)[:usdc_address]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def currency_decimals_for_chain(chain_name)
|
|
78
|
+
currency_config_for_chain(chain_name)[:decimals]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :wallet_address, :facilitator, :chain, :currency, :optimistic
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@wallet_address = ENV.fetch("X402_WALLET_ADDRESS", nil)
|
|
9
|
+
@facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
|
|
10
|
+
@chain = ENV.fetch("X402_CHAIN", "base-sepolia")
|
|
11
|
+
@currency = ENV.fetch("X402_CURRENCY", "USDC")
|
|
12
|
+
@optimistic = ENV.fetch("X402_OPTIMISTIC", "false") == "true" # Default to optimistic mode (fast response, settle after)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate!
|
|
16
|
+
raise ConfigurationError, "wallet_address is required" if wallet_address.nil? || wallet_address.empty?
|
|
17
|
+
raise ConfigurationError, "facilitator URL is required" if facilitator.nil? || facilitator.empty?
|
|
18
|
+
raise ConfigurationError, "chain is required" if chain.nil? || chain.empty?
|
|
19
|
+
raise ConfigurationError, "currency is required" if currency.nil? || currency.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_writer :configuration
|
|
25
|
+
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
yield(configuration)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset_configuration!
|
|
35
|
+
@configuration = Configuration.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class ConfigurationError < StandardError; end
|
|
40
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module X402
|
|
7
|
+
class FacilitatorClient
|
|
8
|
+
attr_reader :facilitator_url
|
|
9
|
+
|
|
10
|
+
def initialize(facilitator_url = nil)
|
|
11
|
+
@facilitator_url = facilitator_url || X402.configuration.facilitator
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify(payment_payload, payment_requirements)
|
|
15
|
+
request_body = {
|
|
16
|
+
x402Version: payment_payload.x402_version,
|
|
17
|
+
paymentPayload: payment_payload.to_h,
|
|
18
|
+
paymentRequirements: payment_requirements
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
::Rails.logger.info("=== X402 Verify Request ===")
|
|
22
|
+
::Rails.logger.info("URL: #{facilitator_url}/verify")
|
|
23
|
+
::Rails.logger.info("Request body: #{request_body.to_json}")
|
|
24
|
+
|
|
25
|
+
response = connection.post("verify") do |req|
|
|
26
|
+
req.headers["Content-Type"] = "application/json"
|
|
27
|
+
req.body = request_body.to_json
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
::Rails.logger.info("Response status: #{response.status}")
|
|
31
|
+
::Rails.logger.info("Response body: #{response.body}")
|
|
32
|
+
|
|
33
|
+
handle_response(response, "verify")
|
|
34
|
+
rescue Faraday::Error => e
|
|
35
|
+
raise FacilitatorError, "Failed to verify payment: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def settle(payment_payload, payment_requirements)
|
|
39
|
+
request_body = {
|
|
40
|
+
x402Version: payment_payload.x402_version,
|
|
41
|
+
paymentPayload: payment_payload.to_h,
|
|
42
|
+
paymentRequirements: payment_requirements
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
::Rails.logger.info("=== X402 Settlement Request ===")
|
|
46
|
+
::Rails.logger.info("URL: #{facilitator_url}/settle")
|
|
47
|
+
::Rails.logger.info("Request body: #{request_body.to_json}")
|
|
48
|
+
|
|
49
|
+
response = connection.post("settle") do |req|
|
|
50
|
+
req.headers["Content-Type"] = "application/json"
|
|
51
|
+
req.body = request_body.to_json
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
::Rails.logger.info("Response status: #{response.status}")
|
|
55
|
+
::Rails.logger.info("Response body: #{response.body}")
|
|
56
|
+
|
|
57
|
+
settlement_data = handle_response(response, "settle")
|
|
58
|
+
SettlementResponse.new(settlement_data)
|
|
59
|
+
rescue Faraday::Error => e
|
|
60
|
+
raise FacilitatorError, "Failed to settle payment: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def supported_networks
|
|
64
|
+
response = connection.get("supported")
|
|
65
|
+
handle_response(response, "supported")
|
|
66
|
+
rescue Faraday::Error => e
|
|
67
|
+
raise FacilitatorError, "Failed to fetch supported networks: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def connection
|
|
73
|
+
@connection ||= Faraday.new(url: facilitator_url) do |faraday|
|
|
74
|
+
faraday.adapter Faraday.default_adapter
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_response(response, action)
|
|
79
|
+
case response.status
|
|
80
|
+
when 200..299
|
|
81
|
+
JSON.parse(response.body)
|
|
82
|
+
when 400
|
|
83
|
+
error_body = JSON.parse(response.body) rescue {}
|
|
84
|
+
raise InvalidPaymentError, "Invalid payment: #{error_body['error'] || response.body}"
|
|
85
|
+
when 500..599
|
|
86
|
+
raise FacilitatorError, "Facilitator error (#{action}): #{response.status}"
|
|
87
|
+
else
|
|
88
|
+
raise FacilitatorError, "Unexpected response (#{action}): #{response.status}"
|
|
89
|
+
end
|
|
90
|
+
rescue JSON::ParserError => e
|
|
91
|
+
raise FacilitatorError, "Failed to parse facilitator response: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class FacilitatorError < StandardError; end
|
|
96
|
+
class InvalidPaymentError < StandardError; end
|
|
97
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class PaymentPayload
|
|
5
|
+
attr_accessor :x402_version, :scheme, :network, :payload
|
|
6
|
+
|
|
7
|
+
def initialize(attributes = {})
|
|
8
|
+
attrs = attributes.with_indifferent_access
|
|
9
|
+
|
|
10
|
+
@x402_version = attrs[:x402Version] || attrs[:x402_version] || 1
|
|
11
|
+
@scheme = attrs[:scheme]
|
|
12
|
+
@network = attrs[:network]
|
|
13
|
+
@payload = attrs[:payload]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_header(header_value)
|
|
17
|
+
return nil if header_value.nil? || header_value.empty?
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
decoded = Base64.strict_decode64(header_value)
|
|
21
|
+
json = JSON.parse(decoded)
|
|
22
|
+
new(json)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
raise InvalidPaymentError, "Failed to decode payment payload: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorization
|
|
29
|
+
@authorization ||= payload&.with_indifferent_access&.[](:authorization)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def signature
|
|
33
|
+
payload&.with_indifferent_access&.[](:signature)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def from_address
|
|
37
|
+
authorization&.with_indifferent_access&.[](:from)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_address
|
|
41
|
+
authorization&.with_indifferent_access&.[](:to)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def value
|
|
45
|
+
authorization&.with_indifferent_access&.[](:value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid_after
|
|
49
|
+
authorization&.with_indifferent_access&.[](:validAfter) || authorization&.with_indifferent_access&.[](:valid_after)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def valid_before
|
|
53
|
+
authorization&.with_indifferent_access&.[](:validBefore) || authorization&.with_indifferent_access&.[](:valid_before)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def nonce
|
|
57
|
+
authorization&.with_indifferent_access&.[](:nonce)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
x402Version: x402_version,
|
|
63
|
+
scheme: scheme,
|
|
64
|
+
network: network,
|
|
65
|
+
payload: payload
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_json(*args)
|
|
70
|
+
to_h.to_json(*args)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class PaymentRequirement
|
|
5
|
+
attr_accessor :scheme, :network, :max_amount_required, :asset, :pay_to, :resource, :description, :max_timeout_seconds, :mime_type, :output_schema, :extra
|
|
6
|
+
|
|
7
|
+
def initialize(attributes = {})
|
|
8
|
+
attrs = attributes.with_indifferent_access
|
|
9
|
+
|
|
10
|
+
@scheme = attrs[:scheme] || "exact"
|
|
11
|
+
@network = attrs[:network]
|
|
12
|
+
@max_amount_required = attrs[:maxAmountRequired] || attrs[:max_amount_required]
|
|
13
|
+
@asset = attrs[:asset]
|
|
14
|
+
@pay_to = attrs[:payTo] || attrs[:pay_to]
|
|
15
|
+
@resource = attrs[:resource]
|
|
16
|
+
@description = attrs[:description]
|
|
17
|
+
@max_timeout_seconds = attrs[:maxTimeoutSeconds] || attrs[:max_timeout_seconds] || 600
|
|
18
|
+
@mime_type = attrs[:mimeType] || attrs[:mime_type] || "application/json"
|
|
19
|
+
@output_schema = attrs[:outputSchema] || attrs[:output_schema]
|
|
20
|
+
@extra = attrs[:extra]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
h = {
|
|
25
|
+
scheme: scheme,
|
|
26
|
+
network: network,
|
|
27
|
+
maxAmountRequired: max_amount_required.to_s,
|
|
28
|
+
asset: asset,
|
|
29
|
+
payTo: pay_to,
|
|
30
|
+
resource: resource,
|
|
31
|
+
description: description,
|
|
32
|
+
maxTimeoutSeconds: max_timeout_seconds,
|
|
33
|
+
mimeType: mime_type
|
|
34
|
+
}
|
|
35
|
+
h[:outputSchema] = output_schema if output_schema
|
|
36
|
+
h[:extra] = extra if extra
|
|
37
|
+
h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_json(*args)
|
|
41
|
+
to_h.to_json(*args)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class PaymentValidator
|
|
5
|
+
attr_reader :facilitator_client
|
|
6
|
+
|
|
7
|
+
def initialize(facilitator_client = nil)
|
|
8
|
+
@facilitator_client = facilitator_client || FacilitatorClient.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def validate(payment_payload, requirement)
|
|
12
|
+
# Validate scheme
|
|
13
|
+
unless payment_payload.scheme == requirement.scheme
|
|
14
|
+
return validation_error("Scheme mismatch: expected #{requirement.scheme}, got #{payment_payload.scheme}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validate network
|
|
18
|
+
unless payment_payload.network == requirement.network
|
|
19
|
+
return validation_error("Network mismatch: expected #{requirement.network}, got #{payment_payload.network}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Validate recipient address
|
|
23
|
+
unless payment_payload.to_address&.downcase == requirement.pay_to&.downcase
|
|
24
|
+
return validation_error("Recipient mismatch: expected #{requirement.pay_to}, got #{payment_payload.to_address}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Validate amount
|
|
28
|
+
payment_value = payment_payload.value.to_i
|
|
29
|
+
required_value = requirement.max_amount_required.to_i
|
|
30
|
+
|
|
31
|
+
if payment_value < required_value
|
|
32
|
+
return validation_error("Insufficient amount: expected at least #{required_value}, got #{payment_value}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Call facilitator to verify payment (does NOT settle on blockchain yet)
|
|
36
|
+
begin
|
|
37
|
+
verify_result = facilitator_client.verify(payment_payload, requirement.to_h)
|
|
38
|
+
|
|
39
|
+
unless verify_result["isValid"]
|
|
40
|
+
return validation_error("Facilitator validation failed: #{verify_result['invalidReason']}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if verify_result["payer"].nil?
|
|
44
|
+
return validation_error("Verification failed: no payer address returned")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
validation_success(verify_result["payer"], verify_result)
|
|
48
|
+
rescue InvalidPaymentError => e
|
|
49
|
+
validation_error("Facilitator validation failed: #{e.message}")
|
|
50
|
+
rescue FacilitatorError => e
|
|
51
|
+
validation_error("Facilitator error: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validation_success(payer_address, data = {})
|
|
58
|
+
{
|
|
59
|
+
valid: true,
|
|
60
|
+
payer: payer_address,
|
|
61
|
+
data: data
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validation_error(message)
|
|
66
|
+
{
|
|
67
|
+
valid: false,
|
|
68
|
+
error: message
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Rails
|
|
5
|
+
module ControllerExtensions
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
after_action :settle_x402_payment_if_needed
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def x402_paywall(options = {})
|
|
13
|
+
amount = options[:amount] or raise ArgumentError, "amount is required"
|
|
14
|
+
chain = options[:chain] || X402.configuration.chain
|
|
15
|
+
currency = options[:currency] || X402.configuration.currency
|
|
16
|
+
|
|
17
|
+
# Check if payment header is present
|
|
18
|
+
payment_header = request.headers["X-PAYMENT"]
|
|
19
|
+
|
|
20
|
+
if payment_header.nil? || payment_header.empty?
|
|
21
|
+
# No payment provided, return 402 with requirements
|
|
22
|
+
return render_payment_required(amount, chain, currency)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Payment provided, validate it
|
|
26
|
+
process_payment(payment_header, amount, chain, currency)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def generate_payment_required_response(amount, chain, currency, error_message = nil)
|
|
32
|
+
requirement_response = X402::RequirementGenerator.generate(
|
|
33
|
+
amount: amount,
|
|
34
|
+
resource: request.original_url,
|
|
35
|
+
description: "Payment required for #{request.path}",
|
|
36
|
+
chain: chain,
|
|
37
|
+
currency: currency
|
|
38
|
+
)
|
|
39
|
+
requirement_response[:error] = error_message if error_message
|
|
40
|
+
requirement_response
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_payment_required(amount, chain, currency)
|
|
44
|
+
requirement_response = generate_payment_required_response(amount, chain, currency)
|
|
45
|
+
|
|
46
|
+
# Detect if request is from browser or API client
|
|
47
|
+
# if browser_request?
|
|
48
|
+
# render_html_paywall(requirement_response)
|
|
49
|
+
# else
|
|
50
|
+
render json: requirement_response, status: :payment_required
|
|
51
|
+
# end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_payment(payment_header, amount, chain, currency)
|
|
55
|
+
# Parse payment payload
|
|
56
|
+
payment_payload = X402::PaymentPayload.from_header(payment_header)
|
|
57
|
+
|
|
58
|
+
# Generate requirement for validation (must match the 402 response exactly!)
|
|
59
|
+
requirement_data = X402::RequirementGenerator.generate(
|
|
60
|
+
amount: amount,
|
|
61
|
+
resource: request.original_url,
|
|
62
|
+
description: "Payment required for #{request.path}",
|
|
63
|
+
chain: chain,
|
|
64
|
+
currency: currency
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
requirement = X402::PaymentRequirement.new(requirement_data[:accepts].first)
|
|
68
|
+
|
|
69
|
+
# Validate payment (verify signature, but don't settle on blockchain yet)
|
|
70
|
+
validator = X402::PaymentValidator.new
|
|
71
|
+
validation_result = validator.validate(payment_payload, requirement)
|
|
72
|
+
|
|
73
|
+
unless validation_result[:valid]
|
|
74
|
+
requirement_response = generate_payment_required_response(amount, chain, currency, validation_result[:error])
|
|
75
|
+
return render json: requirement_response, status: :payment_required
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Store payment info and requirement in request environment
|
|
79
|
+
request.env["x402.payment"] = {
|
|
80
|
+
payer: validation_result[:payer],
|
|
81
|
+
amount: payment_payload.value,
|
|
82
|
+
network: payment_payload.network,
|
|
83
|
+
payload: payment_payload,
|
|
84
|
+
requirement: requirement
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# If non-optimistic mode, settle payment synchronously before continuing
|
|
88
|
+
unless X402.configuration.optimistic
|
|
89
|
+
settlement_result = settle_payment_now
|
|
90
|
+
|
|
91
|
+
# If settlement failed, abort and return 402 with payment requirements
|
|
92
|
+
if settlement_result.nil? || !settlement_result.success?
|
|
93
|
+
error_message = settlement_result&.error_reason || "Settlement failed"
|
|
94
|
+
requirement_response = generate_payment_required_response(amount, chain, currency, "failed to settle payment: #{error_message}")
|
|
95
|
+
return render json: requirement_response, status: :payment_required
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Payment verified, continue with action
|
|
100
|
+
# In optimistic mode, settlement will happen automatically via after_action callback
|
|
101
|
+
rescue X402::InvalidPaymentError => e
|
|
102
|
+
requirement_response = generate_payment_required_response(amount, chain, currency, "Invalid payment: #{e.message}")
|
|
103
|
+
render json: requirement_response, status: :payment_required
|
|
104
|
+
rescue X402::FacilitatorError => e
|
|
105
|
+
requirement_response = generate_payment_required_response(amount, chain, currency, "Verification error: #{e.message}")
|
|
106
|
+
render json: requirement_response, status: :payment_required
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def settle_x402_payment_if_needed
|
|
110
|
+
# Only run in optimistic mode (non-optimistic settles synchronously)
|
|
111
|
+
return unless X402.configuration.optimistic
|
|
112
|
+
|
|
113
|
+
# Only settle if payment was verified
|
|
114
|
+
payment_info = request.env["x402.payment"]
|
|
115
|
+
return unless payment_info
|
|
116
|
+
|
|
117
|
+
# Only settle if response is 2xx (success)
|
|
118
|
+
return unless response.status >= 200 && response.status < 300
|
|
119
|
+
|
|
120
|
+
perform_settlement(payment_info)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def settle_payment_now
|
|
124
|
+
payment_info = request.env["x402.payment"]
|
|
125
|
+
return unless payment_info
|
|
126
|
+
|
|
127
|
+
::Rails.logger.info("=== X402 Non-Optimistic Settlement (before response) ===")
|
|
128
|
+
settlement_result = perform_settlement(payment_info)
|
|
129
|
+
|
|
130
|
+
# Store settlement result for later use (e.g., adding to response body)
|
|
131
|
+
request.env["x402.settlement_result"] = settlement_result
|
|
132
|
+
settlement_result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def perform_settlement(payment_info)
|
|
136
|
+
begin
|
|
137
|
+
::Rails.logger.info("=== X402 Settlement Attempt ===")
|
|
138
|
+
::Rails.logger.info("Optimistic mode: #{X402.configuration.optimistic}")
|
|
139
|
+
::Rails.logger.info("Payment payload class: #{payment_info[:payload].class}")
|
|
140
|
+
::Rails.logger.info("Payment payload: #{payment_info[:payload].inspect}")
|
|
141
|
+
::Rails.logger.info("Requirement class: #{payment_info[:requirement].class}")
|
|
142
|
+
::Rails.logger.info("Requirement hash: #{payment_info[:requirement].to_h.inspect}")
|
|
143
|
+
|
|
144
|
+
facilitator_client = X402::FacilitatorClient.new
|
|
145
|
+
settlement_result = facilitator_client.settle(
|
|
146
|
+
payment_info[:payload],
|
|
147
|
+
payment_info[:requirement].to_h
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if settlement_result.success?
|
|
151
|
+
# Add settlement response header
|
|
152
|
+
response.headers["X-PAYMENT-RESPONSE"] = settlement_result.to_base64
|
|
153
|
+
::Rails.logger.info("x402 settlement successful: #{settlement_result.transaction}")
|
|
154
|
+
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
|
+
::Rails.logger.error("x402 settlement failed: #{settlement_result.error_reason}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
settlement_result
|
|
161
|
+
rescue X402::FacilitatorError => e
|
|
162
|
+
::Rails.logger.error("x402 settlement error: #{e.message}")
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
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
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Rails
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
desc "Creates X402 initializer for your application"
|
|
10
|
+
|
|
11
|
+
def copy_initializer
|
|
12
|
+
template "x402_initializer.rb", "config/initializers/x402.rb"
|
|
13
|
+
|
|
14
|
+
puts ""
|
|
15
|
+
puts "✅ X402 initializer created at config/initializers/x402.rb"
|
|
16
|
+
puts ""
|
|
17
|
+
puts "Next steps:"
|
|
18
|
+
puts "1. Set your X402_WALLET_ADDRESS environment variable"
|
|
19
|
+
puts "2. Configure your preferred chain (base-sepolia for testing, base for production)"
|
|
20
|
+
puts "3. Add x402_paywall(amount: 0.001) to any controller action"
|
|
21
|
+
puts ""
|
|
22
|
+
puts "Example usage:"
|
|
23
|
+
puts " def show"
|
|
24
|
+
puts " x402_paywall(amount: 0.001) # $0.001 payment required"
|
|
25
|
+
puts " render json: @data"
|
|
26
|
+
puts " end"
|
|
27
|
+
puts ""
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# X402 Payment Protocol Configuration
|
|
4
|
+
# For more information, see: https://docs.cdp.coinbase.com/x402
|
|
5
|
+
|
|
6
|
+
X402.configure do |config|
|
|
7
|
+
# Your wallet address (where payments will be received)
|
|
8
|
+
# Set this via environment variable: X402_WALLET_ADDRESS
|
|
9
|
+
config.wallet_address = ENV.fetch("X402_WALLET_ADDRESS", nil)
|
|
10
|
+
|
|
11
|
+
# Facilitator URL (default: https://x402.org/facilitator)
|
|
12
|
+
# The facilitator handles payment verification and settlement
|
|
13
|
+
config.facilitator = ENV.fetch("X402_FACILITATOR_URL", "https://x402.org/facilitator")
|
|
14
|
+
|
|
15
|
+
# Blockchain network to use
|
|
16
|
+
# Options: "base-sepolia" (testnet), "base" (mainnet), "avalanche-fuji" (testnet), "avalanche" (mainnet)
|
|
17
|
+
# For testing, use "base-sepolia". For production, use "base" (no fees!)
|
|
18
|
+
config.chain = ENV.fetch("X402_CHAIN", "base-sepolia")
|
|
19
|
+
|
|
20
|
+
# Currency symbol (currently only USDC is supported)
|
|
21
|
+
config.currency = ENV.fetch("X402_CURRENCY", "USDC")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validate configuration on initialization
|
|
25
|
+
X402.configuration.validate!
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "x402.controller_extensions" do
|
|
7
|
+
ActiveSupport.on_load(:action_controller) do
|
|
8
|
+
include X402::Rails::ControllerExtensions
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "x402.configuration" do
|
|
13
|
+
# Configuration will be loaded from initializers/x402.rb if present
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/x402/rails.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_job"
|
|
5
|
+
require "action_controller"
|
|
6
|
+
require "json"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
require_relative "rails/version"
|
|
10
|
+
require_relative "../x402/configuration"
|
|
11
|
+
require_relative "../x402/chains"
|
|
12
|
+
require_relative "../x402/payment_requirement"
|
|
13
|
+
require_relative "../x402/payment_payload"
|
|
14
|
+
require_relative "../x402/settlement_response"
|
|
15
|
+
require_relative "../x402/facilitator_client"
|
|
16
|
+
require_relative "../x402/payment_validator"
|
|
17
|
+
require_relative "../x402/requirement_generator"
|
|
18
|
+
require_relative "../x402/rails/controller_extensions"
|
|
19
|
+
require_relative "../x402/rails/railtie" if defined?(::Rails::Railtie)
|
|
20
|
+
|
|
21
|
+
module X402
|
|
22
|
+
module Rails
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class RequirementGenerator
|
|
5
|
+
def self.generate(amount:, resource:, description: nil, chain: nil, currency: nil)
|
|
6
|
+
config = X402.configuration
|
|
7
|
+
chain_name = chain || config.chain
|
|
8
|
+
|
|
9
|
+
# Get chain and currency configuration for this specific chain
|
|
10
|
+
chain_config = X402.chain_config(chain_name)
|
|
11
|
+
currency_config = X402.currency_config_for_chain(chain_name)
|
|
12
|
+
|
|
13
|
+
# Convert amount to atomic units
|
|
14
|
+
atomic_amount = convert_to_atomic(amount, currency_config[:decimals])
|
|
15
|
+
|
|
16
|
+
# Get asset address (USDC contract address for the chain)
|
|
17
|
+
asset_address = X402.usdc_address_for(chain_name)
|
|
18
|
+
|
|
19
|
+
# Build EIP-712 domain info (required for signature verification)
|
|
20
|
+
# IMPORTANT: The name must match what the USDC contract returns for name()
|
|
21
|
+
# Testnets use "USDC", mainnets use "USD Coin" or "USDC" depending on chain
|
|
22
|
+
eip712_domain = {
|
|
23
|
+
name: currency_config[:name],
|
|
24
|
+
version: currency_config[:version]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Create payment requirement
|
|
28
|
+
requirement = PaymentRequirement.new(
|
|
29
|
+
scheme: "exact",
|
|
30
|
+
network: chain_name,
|
|
31
|
+
max_amount_required: atomic_amount,
|
|
32
|
+
asset: asset_address,
|
|
33
|
+
pay_to: config.wallet_address,
|
|
34
|
+
resource: resource,
|
|
35
|
+
description: description || "Payment required for #{resource}",
|
|
36
|
+
max_timeout_seconds: 600,
|
|
37
|
+
mime_type: "application/json",
|
|
38
|
+
extra: eip712_domain
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Build full response
|
|
42
|
+
{
|
|
43
|
+
x402Version: 1,
|
|
44
|
+
error: "Payment required to access this resource",
|
|
45
|
+
accepts: [requirement.to_h]
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.convert_to_atomic(amount, decimals)
|
|
50
|
+
# Convert USD amount to atomic units
|
|
51
|
+
# For USDC with 6 decimals: $0.001 = 1000 atomic units
|
|
52
|
+
(amount.to_f * (10**decimals)).to_i
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class SettlementResponse
|
|
5
|
+
attr_accessor :success, :transaction, :network, :payer, :error_reason
|
|
6
|
+
|
|
7
|
+
def initialize(attributes = {})
|
|
8
|
+
@success = attributes[:success] || attributes["success"]
|
|
9
|
+
@transaction = attributes[:transaction] || attributes["transaction"]
|
|
10
|
+
@network = attributes[:network] || attributes["network"]
|
|
11
|
+
@payer = attributes[:payer] || attributes["payer"]
|
|
12
|
+
@error_reason = attributes[:error_reason] || attributes["errorReason"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def success?
|
|
16
|
+
success == true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{
|
|
21
|
+
success: success,
|
|
22
|
+
transaction: transaction,
|
|
23
|
+
network: network,
|
|
24
|
+
payer: payer,
|
|
25
|
+
errorReason: error_reason
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_json(*args)
|
|
30
|
+
to_h.to_json(*args)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_base64
|
|
34
|
+
Base64.strict_encode64(to_json)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: x402-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- QuickNode
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-10-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 7.0.0
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 7.0.0
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: faraday-follow_redirects
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.3'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.3'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '13.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '13.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rspec
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rspec-rails
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '6.0'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '6.0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: webmock
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '3.0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '3.0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: vcr
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '6.0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '6.0'
|
|
125
|
+
description: Accept instant blockchain micropayments in Rails applications using the
|
|
126
|
+
x402 protocol. Enable HTTP 402 Payment Required with USDC payments on Base and other
|
|
127
|
+
networks.
|
|
128
|
+
email:
|
|
129
|
+
- zach@quiknode.io
|
|
130
|
+
executables: []
|
|
131
|
+
extensions: []
|
|
132
|
+
extra_rdoc_files: []
|
|
133
|
+
files:
|
|
134
|
+
- ".rspec"
|
|
135
|
+
- LICENSE.txt
|
|
136
|
+
- README.md
|
|
137
|
+
- Rakefile
|
|
138
|
+
- coverage/coverage.svg
|
|
139
|
+
- lib/x402/chains.rb
|
|
140
|
+
- lib/x402/configuration.rb
|
|
141
|
+
- lib/x402/facilitator_client.rb
|
|
142
|
+
- lib/x402/payment_payload.rb
|
|
143
|
+
- lib/x402/payment_requirement.rb
|
|
144
|
+
- lib/x402/payment_validator.rb
|
|
145
|
+
- lib/x402/rails.rb
|
|
146
|
+
- lib/x402/rails/controller_extensions.rb
|
|
147
|
+
- lib/x402/rails/generators/install_generator.rb
|
|
148
|
+
- lib/x402/rails/generators/templates/x402_initializer.rb
|
|
149
|
+
- lib/x402/rails/railtie.rb
|
|
150
|
+
- lib/x402/rails/version.rb
|
|
151
|
+
- lib/x402/requirement_generator.rb
|
|
152
|
+
- lib/x402/settlement_response.rb
|
|
153
|
+
homepage: https://github.com/quiknode-labs/x402-rails
|
|
154
|
+
licenses:
|
|
155
|
+
- MIT
|
|
156
|
+
metadata:
|
|
157
|
+
homepage_uri: https://github.com/quiknode-labs/x402-rails
|
|
158
|
+
source_code_uri: https://github.com/quiknode-labs/x402-rails
|
|
159
|
+
post_install_message:
|
|
160
|
+
rdoc_options: []
|
|
161
|
+
require_paths:
|
|
162
|
+
- lib
|
|
163
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
164
|
+
requirements:
|
|
165
|
+
- - ">="
|
|
166
|
+
- !ruby/object:Gem::Version
|
|
167
|
+
version: 3.0.0
|
|
168
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
169
|
+
requirements:
|
|
170
|
+
- - ">="
|
|
171
|
+
- !ruby/object:Gem::Version
|
|
172
|
+
version: '0'
|
|
173
|
+
requirements: []
|
|
174
|
+
rubygems_version: 3.5.16
|
|
175
|
+
signing_key:
|
|
176
|
+
specification_version: 4
|
|
177
|
+
summary: Rails integration for x402 payment protocol
|
|
178
|
+
test_files: []
|