x402.rb 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/LICENSE +21 -0
- data/README.md +148 -0
- data/lib/x402/client/middleware.rb +50 -0
- data/lib/x402/configuration.rb +29 -0
- data/lib/x402/facilitator.rb +94 -0
- data/lib/x402/header.rb +32 -0
- data/lib/x402/server/middleware.rb +127 -0
- data/lib/x402/types/payment_payload.rb +54 -0
- data/lib/x402/types/payment_required.rb +48 -0
- data/lib/x402/types/payment_requirements.rb +48 -0
- data/lib/x402/types/resource_info.rb +32 -0
- data/lib/x402/types/settlement_response.rb +58 -0
- data/lib/x402/types/verify_response.rb +27 -0
- data/lib/x402/version.rb +5 -0
- data/lib/x402.rb +14 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6d7c4bbda7de50fffc403636c6951b355f6b782a7d0d6332c0287f501aac2b23
|
|
4
|
+
data.tar.gz: fa12d2bc84fe3db789ee7db772d0a94a603f86752b61aeef455dc6db01456ca0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2cbea85e5c81a576cd9911ffafa5af426ce921665a4e2b45dd4cb157ed69e81f81836d5aea970c3677c46aa5b9bac04a0bcef7a2786fdc88ce1c2c1220bba5bc
|
|
7
|
+
data.tar.gz: 21096796768eaa15b2f2e5bac1e6ed8e19d15c2f151c5a7979e9502ea4392f16c1df7575372450648600ce6bba534992d6ad8b2afaffa702e95aa62cc948467e
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Filippo
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# x402.rb
|
|
2
|
+
|
|
3
|
+
Ruby gem for the [x402 protocol](https://docs.x402.org) — HTTP 402-based on-chain payments for APIs and services.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "x402.rb"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Components
|
|
12
|
+
|
|
13
|
+
### Facilitator Client
|
|
14
|
+
|
|
15
|
+
Interact with an x402 facilitator to verify and settle payments:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "x402"
|
|
19
|
+
|
|
20
|
+
facilitator = X402::Facilitator.new
|
|
21
|
+
# or: X402::Facilitator.new(base_url: "https://api.cdp.coinbase.com/platform/v2/x402")
|
|
22
|
+
|
|
23
|
+
# Check what's supported
|
|
24
|
+
facilitator.supported
|
|
25
|
+
|
|
26
|
+
# Verify a payment
|
|
27
|
+
result = facilitator.verify(
|
|
28
|
+
payment_payload: payload,
|
|
29
|
+
payment_requirements: requirements
|
|
30
|
+
)
|
|
31
|
+
result.valid? # => true
|
|
32
|
+
result.payer # => "0x..."
|
|
33
|
+
|
|
34
|
+
# Settle a payment
|
|
35
|
+
settlement = facilitator.settle(
|
|
36
|
+
payment_payload: payload,
|
|
37
|
+
payment_requirements: requirements
|
|
38
|
+
)
|
|
39
|
+
settlement.success? # => true
|
|
40
|
+
settlement.transaction # => "0xabc..."
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Server Middleware (Rack)
|
|
44
|
+
|
|
45
|
+
Gate routes behind x402 payments. Works with Rails, Sinatra, and any Rack app:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# config/application.rb (Rails)
|
|
49
|
+
config.middleware.use X402::Server::Middleware, routes: {
|
|
50
|
+
"GET /api/premium" => {
|
|
51
|
+
accepts: [{
|
|
52
|
+
scheme: "exact",
|
|
53
|
+
network: "eip155:84532",
|
|
54
|
+
amount: "10000", # atomic units (USDC has 6 decimals)
|
|
55
|
+
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
56
|
+
pay_to: "0xYourWalletAddress",
|
|
57
|
+
max_timeout_seconds: 60,
|
|
58
|
+
extra: { "name" => "USDC", "version" => "2" }
|
|
59
|
+
}],
|
|
60
|
+
description: "Premium market data",
|
|
61
|
+
mime_type: "application/json"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# Sinatra
|
|
68
|
+
use X402::Server::Middleware, routes: { ... }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The middleware:
|
|
72
|
+
1. Returns `402` with a `PAYMENT-REQUIRED` header for gated routes without payment
|
|
73
|
+
2. Verifies payment via the facilitator when a `PAYMENT-SIGNATURE` header is present
|
|
74
|
+
3. Settles the payment after the downstream app responds successfully
|
|
75
|
+
4. Returns the `PAYMENT-RESPONSE` header with settlement details
|
|
76
|
+
|
|
77
|
+
### Client Middleware (Faraday)
|
|
78
|
+
|
|
79
|
+
Automatically handle 402 responses by signing and retrying:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
require "faraday"
|
|
83
|
+
require "x402"
|
|
84
|
+
require "x402/client/middleware"
|
|
85
|
+
|
|
86
|
+
conn = Faraday.new("https://api.example.com") do |f|
|
|
87
|
+
f.use X402::Client::Middleware, signer: my_signer
|
|
88
|
+
f.adapter Faraday.default_adapter
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Transparently pays for 402-gated resources
|
|
92
|
+
response = conn.get("/api/premium")
|
|
93
|
+
response.body # => the paid content
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The `signer` must implement `#sign(payment_required)` and return an `X402::Types::PaymentPayload` (or `nil` to skip payment). This is where you integrate your wallet/key management.
|
|
97
|
+
|
|
98
|
+
### Configuration
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
X402.configure do |c|
|
|
102
|
+
c.facilitator_url = "https://x402.org/facilitator" # default (testnet)
|
|
103
|
+
c.x402_version = 2 # default
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Protocol Flow
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
Client Server Facilitator
|
|
111
|
+
| | |
|
|
112
|
+
| GET /premium | |
|
|
113
|
+
|------------------------------>| |
|
|
114
|
+
| | |
|
|
115
|
+
| 402 + PAYMENT-REQUIRED | |
|
|
116
|
+
|<------------------------------| |
|
|
117
|
+
| | |
|
|
118
|
+
| GET /premium | |
|
|
119
|
+
| + PAYMENT-SIGNATURE | |
|
|
120
|
+
|------------------------------>| |
|
|
121
|
+
| | POST /verify |
|
|
122
|
+
| |---------------------------->|
|
|
123
|
+
| | { isValid: true } |
|
|
124
|
+
| |<----------------------------|
|
|
125
|
+
| | |
|
|
126
|
+
| | POST /settle |
|
|
127
|
+
| |---------------------------->|
|
|
128
|
+
| | { success, transaction } |
|
|
129
|
+
| |<----------------------------|
|
|
130
|
+
| | |
|
|
131
|
+
| 200 + PAYMENT-RESPONSE | |
|
|
132
|
+
|<------------------------------| |
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Types
|
|
136
|
+
|
|
137
|
+
- `X402::Types::PaymentRequired` — server's 402 response with accepted payment options
|
|
138
|
+
- `X402::Types::PaymentRequirements` — a single payment option (scheme, network, amount, asset, payTo)
|
|
139
|
+
- `X402::Types::PaymentPayload` — client's signed payment authorization
|
|
140
|
+
- `X402::Types::SettlementResponse` — settlement result with transaction hash
|
|
141
|
+
- `X402::Types::VerifyResponse` — verification result from facilitator
|
|
142
|
+
- `X402::Types::ResourceInfo` — URL, description, and MIME type of the resource
|
|
143
|
+
|
|
144
|
+
All types support `#to_h`, `.from_h(hash)`, `#encode` (Base64 JSON), and `.decode(string)`.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Client
|
|
5
|
+
# Faraday middleware that automatically handles x402 payment flows.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# conn = Faraday.new("https://api.example.com") do |f|
|
|
9
|
+
# f.use X402::Client::Middleware, signer: my_signer
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# The signer must respond to #sign(payment_requirements, resource) and return
|
|
13
|
+
# a PaymentPayload (or a Hash with the same structure).
|
|
14
|
+
#
|
|
15
|
+
class Middleware < Faraday::Middleware
|
|
16
|
+
def initialize(app, signer:)
|
|
17
|
+
super(app)
|
|
18
|
+
@signer = signer
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
response = @app.call(env)
|
|
23
|
+
|
|
24
|
+
return response unless response.status == 402
|
|
25
|
+
|
|
26
|
+
# Parse the payment requirements
|
|
27
|
+
encoded = response.headers[Header::PAYMENT_REQUIRED] ||
|
|
28
|
+
response.headers[Header::PAYMENT_REQUIRED.downcase]
|
|
29
|
+
|
|
30
|
+
return response unless encoded
|
|
31
|
+
|
|
32
|
+
payment_required = Types::PaymentRequired.decode(encoded)
|
|
33
|
+
|
|
34
|
+
# Let the signer pick a payment option and produce a signed payload
|
|
35
|
+
payment_payload = @signer.sign(payment_required)
|
|
36
|
+
return response unless payment_payload
|
|
37
|
+
|
|
38
|
+
payment_payload = if payment_payload.is_a?(Types::PaymentPayload)
|
|
39
|
+
payment_payload
|
|
40
|
+
else
|
|
41
|
+
Types::PaymentPayload.new(**payment_payload)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Retry the request with the payment header
|
|
45
|
+
env.request_headers[Header::PAYMENT_SIGNATURE] = payment_payload.encode
|
|
46
|
+
@app.call(env)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :facilitator_url, :x402_version
|
|
6
|
+
|
|
7
|
+
TESTNET_FACILITATOR = "https://x402.org/facilitator"
|
|
8
|
+
PRODUCTION_FACILITATOR = "https://api.cdp.coinbase.com/platform/v2/x402"
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@facilitator_url = TESTNET_FACILITATOR
|
|
12
|
+
@x402_version = 2
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset_configuration!
|
|
26
|
+
@configuration = Configuration.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module X402
|
|
8
|
+
class Facilitator
|
|
9
|
+
attr_reader :base_url
|
|
10
|
+
|
|
11
|
+
def initialize(base_url: nil)
|
|
12
|
+
@base_url = base_url || X402.configuration.facilitator_url
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Verify a payment authorization without executing it.
|
|
16
|
+
#
|
|
17
|
+
# @param payment_payload [PaymentPayload, Hash] the client's payment payload
|
|
18
|
+
# @param payment_requirements [PaymentRequirements, Hash] the requirements to verify against
|
|
19
|
+
# @return [Types::VerifyResponse]
|
|
20
|
+
def verify(payment_payload:, payment_requirements:)
|
|
21
|
+
body = {
|
|
22
|
+
x402Version: X402.configuration.x402_version,
|
|
23
|
+
paymentPayload: normalize(payment_payload),
|
|
24
|
+
paymentRequirements: normalize(payment_requirements)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
response = post("/verify", body)
|
|
28
|
+
Types::VerifyResponse.from_h(response)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Settle (execute) a verified payment on-chain.
|
|
32
|
+
#
|
|
33
|
+
# @param payment_payload [PaymentPayload, Hash] the client's payment payload
|
|
34
|
+
# @param payment_requirements [PaymentRequirements, Hash] the requirements that were accepted
|
|
35
|
+
# @return [Types::SettlementResponse]
|
|
36
|
+
def settle(payment_payload:, payment_requirements:)
|
|
37
|
+
body = {
|
|
38
|
+
x402Version: X402.configuration.x402_version,
|
|
39
|
+
paymentPayload: normalize(payment_payload),
|
|
40
|
+
paymentRequirements: normalize(payment_requirements)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
response = post("/settle", body)
|
|
44
|
+
Types::SettlementResponse.from_h(response)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get supported schemes, networks, and extensions.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash]
|
|
50
|
+
def supported
|
|
51
|
+
get("/supported")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalize(obj)
|
|
57
|
+
obj.respond_to?(:to_h) ? obj.to_h : obj
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def post(path, body)
|
|
61
|
+
uri = URI.parse("#{base_url.chomp("/")}#{path}")
|
|
62
|
+
request = Net::HTTP::Post.new(uri)
|
|
63
|
+
request["Content-Type"] = "application/json"
|
|
64
|
+
request.body = JSON.generate(body)
|
|
65
|
+
|
|
66
|
+
execute(uri, request)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get(path)
|
|
70
|
+
uri = URI.parse("#{base_url.chomp("/")}#{path}")
|
|
71
|
+
request = Net::HTTP::Get.new(uri)
|
|
72
|
+
request["Accept"] = "application/json"
|
|
73
|
+
|
|
74
|
+
execute(uri, request)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def execute(uri, request)
|
|
78
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
79
|
+
http.use_ssl = uri.scheme == "https"
|
|
80
|
+
|
|
81
|
+
response = http.request(request)
|
|
82
|
+
|
|
83
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
84
|
+
raise FacilitatorError, "Facilitator returned #{response.code}: #{response.body}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
JSON.parse(response.body)
|
|
88
|
+
rescue JSON::ParserError => e
|
|
89
|
+
raise FacilitatorError, "Invalid JSON from facilitator: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class FacilitatorError < StandardError; end
|
|
94
|
+
end
|
data/lib/x402/header.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module X402
|
|
7
|
+
module Header
|
|
8
|
+
PAYMENT_REQUIRED = "PAYMENT-REQUIRED"
|
|
9
|
+
PAYMENT_SIGNATURE = "PAYMENT-SIGNATURE"
|
|
10
|
+
PAYMENT_RESPONSE = "PAYMENT-RESPONSE"
|
|
11
|
+
|
|
12
|
+
# Rack normalizes headers to HTTP_UPPER_SNAKE format
|
|
13
|
+
RACK_PAYMENT_REQUIRED = "HTTP_PAYMENT_REQUIRED"
|
|
14
|
+
RACK_PAYMENT_SIGNATURE = "HTTP_PAYMENT_SIGNATURE"
|
|
15
|
+
RACK_PAYMENT_RESPONSE = "HTTP_PAYMENT_RESPONSE"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def encode(data)
|
|
19
|
+
json = data.is_a?(String) ? data : JSON.generate(data)
|
|
20
|
+
Base64.strict_encode64(json)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def decode(value)
|
|
24
|
+
JSON.parse(Base64.strict_decode64(value))
|
|
25
|
+
rescue ArgumentError, JSON::ParserError => e
|
|
26
|
+
raise DecodeError, "Failed to decode x402 header: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class DecodeError < StandardError; end
|
|
32
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Server
|
|
5
|
+
# Rack middleware that gates routes behind x402 payments.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# use X402::Server::Middleware, routes: {
|
|
9
|
+
# "GET /premium" => {
|
|
10
|
+
# accepts: [{
|
|
11
|
+
# scheme: "exact",
|
|
12
|
+
# network: "eip155:84532",
|
|
13
|
+
# amount: "10000",
|
|
14
|
+
# asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
15
|
+
# pay_to: "0xYourAddress",
|
|
16
|
+
# max_timeout_seconds: 60,
|
|
17
|
+
# extra: { name: "USDC", version: "2" }
|
|
18
|
+
# }],
|
|
19
|
+
# description: "Premium content",
|
|
20
|
+
# mime_type: "application/json"
|
|
21
|
+
# }
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
24
|
+
class Middleware
|
|
25
|
+
def initialize(app, routes:, facilitator: nil)
|
|
26
|
+
@app = app
|
|
27
|
+
@routes = normalize_routes(routes)
|
|
28
|
+
@facilitator = facilitator || X402::Facilitator.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(env)
|
|
32
|
+
route_key = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
|
|
33
|
+
route_config = @routes[route_key]
|
|
34
|
+
|
|
35
|
+
# Not a gated route — pass through
|
|
36
|
+
return @app.call(env) unless route_config
|
|
37
|
+
|
|
38
|
+
payment_header = env[Header::RACK_PAYMENT_SIGNATURE]
|
|
39
|
+
|
|
40
|
+
# No payment provided — return 402
|
|
41
|
+
unless payment_header
|
|
42
|
+
return payment_required_response(env, route_config)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Decode and verify payment
|
|
46
|
+
payment_payload = Types::PaymentPayload.decode(payment_header)
|
|
47
|
+
accepted = payment_payload.accepted
|
|
48
|
+
|
|
49
|
+
# Find matching requirements
|
|
50
|
+
matching = route_config[:accepts].find do |req|
|
|
51
|
+
req.scheme == accepted.scheme && req.network == accepted.network
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
unless matching
|
|
55
|
+
return payment_required_response(env, route_config,
|
|
56
|
+
error: "No matching payment scheme/network")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Verify via facilitator
|
|
60
|
+
verify_result = @facilitator.verify(
|
|
61
|
+
payment_payload: payment_payload,
|
|
62
|
+
payment_requirements: matching
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
unless verify_result.valid?
|
|
66
|
+
return payment_required_response(env, route_config,
|
|
67
|
+
error: "Payment verification failed: #{verify_result.invalid_reason}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Call the downstream app
|
|
71
|
+
status, headers, body = @app.call(env)
|
|
72
|
+
|
|
73
|
+
# Settle the payment
|
|
74
|
+
settlement = @facilitator.settle(
|
|
75
|
+
payment_payload: payment_payload,
|
|
76
|
+
payment_requirements: matching
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
headers[Header::PAYMENT_RESPONSE] = settlement.encode
|
|
80
|
+
|
|
81
|
+
if settlement.success?
|
|
82
|
+
[status, headers, body]
|
|
83
|
+
else
|
|
84
|
+
payment_required_response(env, route_config,
|
|
85
|
+
error: "Settlement failed: #{settlement.error_reason}")
|
|
86
|
+
end
|
|
87
|
+
rescue DecodeError => e
|
|
88
|
+
[400, {"content-type" => "application/json"},
|
|
89
|
+
[JSON.generate({error: e.message})]]
|
|
90
|
+
rescue FacilitatorError => e
|
|
91
|
+
[500, {"content-type" => "application/json"},
|
|
92
|
+
[JSON.generate({error: e.message})]]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def payment_required_response(env, route_config, error: nil)
|
|
98
|
+
payment_required = Types::PaymentRequired.new(
|
|
99
|
+
resource: {
|
|
100
|
+
url: "#{env["rack.url_scheme"]}://#{env["HTTP_HOST"]}#{env["PATH_INFO"]}",
|
|
101
|
+
description: route_config[:description],
|
|
102
|
+
mime_type: route_config[:mime_type]
|
|
103
|
+
},
|
|
104
|
+
accepts: route_config[:accepts],
|
|
105
|
+
error: error || "#{Header::PAYMENT_SIGNATURE} header is required"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
headers = {
|
|
109
|
+
"content-type" => "application/json",
|
|
110
|
+
Header::PAYMENT_REQUIRED => payment_required.encode
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
[402, headers, [JSON.generate(payment_required.to_h)]]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def normalize_routes(routes)
|
|
117
|
+
routes.transform_values do |config|
|
|
118
|
+
config = config.dup
|
|
119
|
+
config[:accepts] = config[:accepts].map do |a|
|
|
120
|
+
a.is_a?(Types::PaymentRequirements) ? a : Types::PaymentRequirements.new(**a)
|
|
121
|
+
end
|
|
122
|
+
config
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class PaymentPayload
|
|
6
|
+
attr_accessor :x402_version, :resource, :accepted, :payload, :extensions
|
|
7
|
+
|
|
8
|
+
def initialize(accepted:, payload:, resource: nil, x402_version: nil, extensions: nil)
|
|
9
|
+
@x402_version = x402_version || X402.configuration.x402_version
|
|
10
|
+
@resource = if resource.is_a?(Hash)
|
|
11
|
+
ResourceInfo.new(**resource)
|
|
12
|
+
else
|
|
13
|
+
resource
|
|
14
|
+
end
|
|
15
|
+
@accepted = if accepted.is_a?(Hash)
|
|
16
|
+
PaymentRequirements.new(**accepted)
|
|
17
|
+
else
|
|
18
|
+
accepted
|
|
19
|
+
end
|
|
20
|
+
@payload = payload
|
|
21
|
+
@extensions = extensions
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
h = {
|
|
26
|
+
x402Version: x402_version,
|
|
27
|
+
accepted: accepted.to_h,
|
|
28
|
+
payload: payload
|
|
29
|
+
}
|
|
30
|
+
h[:resource] = resource.to_h if resource
|
|
31
|
+
h[:extensions] = extensions if extensions
|
|
32
|
+
h
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def encode
|
|
36
|
+
Header.encode(to_h)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.from_h(hash)
|
|
40
|
+
new(
|
|
41
|
+
x402_version: hash["x402Version"],
|
|
42
|
+
resource: ResourceInfo.from_h(hash["resource"]),
|
|
43
|
+
accepted: PaymentRequirements.from_h(hash["accepted"]),
|
|
44
|
+
payload: hash["payload"],
|
|
45
|
+
extensions: hash["extensions"]
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.decode(encoded_string)
|
|
50
|
+
from_h(Header.decode(encoded_string))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class PaymentRequired
|
|
6
|
+
attr_accessor :x402_version, :error, :resource, :accepts, :extensions
|
|
7
|
+
|
|
8
|
+
def initialize(resource:, accepts:, error: nil, x402_version: nil, extensions: nil)
|
|
9
|
+
@x402_version = x402_version || X402.configuration.x402_version
|
|
10
|
+
@error = error
|
|
11
|
+
@resource = resource.is_a?(ResourceInfo) ? resource : ResourceInfo.new(**resource)
|
|
12
|
+
@accepts = accepts.map do |a|
|
|
13
|
+
a.is_a?(PaymentRequirements) ? a : PaymentRequirements.new(**a)
|
|
14
|
+
end
|
|
15
|
+
@extensions = extensions
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
h = {
|
|
20
|
+
x402Version: x402_version,
|
|
21
|
+
resource: resource.to_h,
|
|
22
|
+
accepts: accepts.map(&:to_h)
|
|
23
|
+
}
|
|
24
|
+
h[:error] = error if error
|
|
25
|
+
h[:extensions] = extensions if extensions
|
|
26
|
+
h
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def encode
|
|
30
|
+
Header.encode(to_h)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_h(hash)
|
|
34
|
+
new(
|
|
35
|
+
x402_version: hash["x402Version"],
|
|
36
|
+
error: hash["error"],
|
|
37
|
+
resource: ResourceInfo.from_h(hash["resource"]),
|
|
38
|
+
accepts: (hash["accepts"] || []).map { |a| PaymentRequirements.from_h(a) },
|
|
39
|
+
extensions: hash["extensions"]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.decode(encoded_string)
|
|
44
|
+
from_h(Header.decode(encoded_string))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class PaymentRequirements
|
|
6
|
+
attr_accessor :scheme, :network, :amount, :asset, :pay_to,
|
|
7
|
+
:max_timeout_seconds, :extra
|
|
8
|
+
|
|
9
|
+
def initialize(scheme:, network:, amount:, asset:, pay_to:,
|
|
10
|
+
max_timeout_seconds: 60, extra: nil)
|
|
11
|
+
@scheme = scheme
|
|
12
|
+
@network = network
|
|
13
|
+
@amount = amount.to_s
|
|
14
|
+
@asset = asset
|
|
15
|
+
@pay_to = pay_to
|
|
16
|
+
@max_timeout_seconds = max_timeout_seconds
|
|
17
|
+
@extra = extra
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
h = {
|
|
22
|
+
scheme: scheme,
|
|
23
|
+
network: network,
|
|
24
|
+
amount: amount,
|
|
25
|
+
asset: asset,
|
|
26
|
+
payTo: pay_to,
|
|
27
|
+
maxTimeoutSeconds: max_timeout_seconds
|
|
28
|
+
}
|
|
29
|
+
h[:extra] = extra if extra
|
|
30
|
+
h
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_h(hash)
|
|
34
|
+
return nil if hash.nil?
|
|
35
|
+
|
|
36
|
+
new(
|
|
37
|
+
scheme: hash["scheme"],
|
|
38
|
+
network: hash["network"],
|
|
39
|
+
amount: hash["amount"],
|
|
40
|
+
asset: hash["asset"],
|
|
41
|
+
pay_to: hash["payTo"],
|
|
42
|
+
max_timeout_seconds: hash["maxTimeoutSeconds"] || 60,
|
|
43
|
+
extra: hash["extra"]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class ResourceInfo
|
|
6
|
+
attr_accessor :url, :description, :mime_type
|
|
7
|
+
|
|
8
|
+
def initialize(url:, description: nil, mime_type: nil)
|
|
9
|
+
@url = url
|
|
10
|
+
@description = description
|
|
11
|
+
@mime_type = mime_type
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
h = {url: url}
|
|
16
|
+
h[:description] = description if description
|
|
17
|
+
h[:mimeType] = mime_type if mime_type
|
|
18
|
+
h
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.from_h(hash)
|
|
22
|
+
return nil if hash.nil?
|
|
23
|
+
|
|
24
|
+
new(
|
|
25
|
+
url: hash["url"],
|
|
26
|
+
description: hash["description"],
|
|
27
|
+
mime_type: hash["mimeType"]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class SettlementResponse
|
|
6
|
+
attr_accessor :success, :error_reason, :payer, :transaction,
|
|
7
|
+
:network, :amount, :extensions
|
|
8
|
+
|
|
9
|
+
def initialize(success:, transaction:, network:, payer: nil,
|
|
10
|
+
error_reason: nil, amount: nil, extensions: nil)
|
|
11
|
+
@success = success
|
|
12
|
+
@transaction = transaction
|
|
13
|
+
@network = network
|
|
14
|
+
@payer = payer
|
|
15
|
+
@error_reason = error_reason
|
|
16
|
+
@amount = amount
|
|
17
|
+
@extensions = extensions
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def success?
|
|
21
|
+
success
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
h = {
|
|
26
|
+
success: success,
|
|
27
|
+
transaction: transaction,
|
|
28
|
+
network: network
|
|
29
|
+
}
|
|
30
|
+
h[:errorReason] = error_reason if error_reason
|
|
31
|
+
h[:payer] = payer if payer
|
|
32
|
+
h[:amount] = amount if amount
|
|
33
|
+
h[:extensions] = extensions if extensions
|
|
34
|
+
h
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def encode
|
|
38
|
+
Header.encode(to_h)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.from_h(hash)
|
|
42
|
+
new(
|
|
43
|
+
success: hash["success"],
|
|
44
|
+
transaction: hash["transaction"],
|
|
45
|
+
network: hash["network"],
|
|
46
|
+
payer: hash["payer"],
|
|
47
|
+
error_reason: hash["errorReason"],
|
|
48
|
+
amount: hash["amount"],
|
|
49
|
+
extensions: hash["extensions"]
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.decode(encoded_string)
|
|
54
|
+
from_h(Header.decode(encoded_string))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X402
|
|
4
|
+
module Types
|
|
5
|
+
class VerifyResponse
|
|
6
|
+
attr_accessor :valid, :invalid_reason, :payer
|
|
7
|
+
|
|
8
|
+
def initialize(valid:, invalid_reason: nil, payer: nil)
|
|
9
|
+
@valid = valid
|
|
10
|
+
@invalid_reason = invalid_reason
|
|
11
|
+
@payer = payer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def valid?
|
|
15
|
+
valid
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_h(hash)
|
|
19
|
+
new(
|
|
20
|
+
valid: hash["isValid"],
|
|
21
|
+
invalid_reason: hash["invalidReason"],
|
|
22
|
+
payer: hash["payer"]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/x402/version.rb
ADDED
data/lib/x402.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "x402/version"
|
|
4
|
+
require_relative "x402/configuration"
|
|
5
|
+
require_relative "x402/header"
|
|
6
|
+
require_relative "x402/types/resource_info"
|
|
7
|
+
require_relative "x402/types/payment_requirements"
|
|
8
|
+
require_relative "x402/types/payment_required"
|
|
9
|
+
require_relative "x402/types/payment_payload"
|
|
10
|
+
require_relative "x402/types/settlement_response"
|
|
11
|
+
require_relative "x402/types/verify_response"
|
|
12
|
+
require_relative "x402/facilitator"
|
|
13
|
+
require_relative "x402/server/middleware"
|
|
14
|
+
require_relative "x402/client/middleware" if defined?(Faraday)
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: x402.rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Filippo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
40
|
+
description: Enables HTTP 402-based on-chain payments for APIs and services using
|
|
41
|
+
the x402 open standard. Includes Rack server middleware, Faraday client middleware,
|
|
42
|
+
and a facilitator API client.
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/x402.rb
|
|
50
|
+
- lib/x402/client/middleware.rb
|
|
51
|
+
- lib/x402/configuration.rb
|
|
52
|
+
- lib/x402/facilitator.rb
|
|
53
|
+
- lib/x402/header.rb
|
|
54
|
+
- lib/x402/server/middleware.rb
|
|
55
|
+
- lib/x402/types/payment_payload.rb
|
|
56
|
+
- lib/x402/types/payment_required.rb
|
|
57
|
+
- lib/x402/types/payment_requirements.rb
|
|
58
|
+
- lib/x402/types/resource_info.rb
|
|
59
|
+
- lib/x402/types/settlement_response.rb
|
|
60
|
+
- lib/x402/types/verify_response.rb
|
|
61
|
+
- lib/x402/version.rb
|
|
62
|
+
homepage: https://github.com/filippo/x402.rb
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://github.com/filippo/x402.rb
|
|
67
|
+
source_code_uri: https://github.com/filippo/x402.rb
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.1'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 4.0.8
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Ruby implementation of the x402 payment protocol
|
|
85
|
+
test_files: []
|