weft-sdk 0.25.0 → 0.32.2
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/docs/FetchApi.md +3 -3
- data/docs/FetchErrorResponse.md +1 -1
- data/docs/FetchRequest.md +6 -2
- data/docs/FetchResponse.md +5 -3
- data/docs/GetPurchaseIdParameter.md +48 -0
- data/docs/PaymentsApi.md +2 -0
- data/docs/Purchase.md +6 -0
- data/docs/PurchasesApi.md +2 -2
- data/docs/SearchApi.md +1 -1
- data/docs/SearchCompactAccess.md +23 -0
- data/docs/SearchCompactAccessPrice.md +23 -0
- data/docs/SearchCompactNextCall.md +23 -0
- data/docs/SearchCompactResult.md +47 -0
- data/docs/SearchCompactResultAttribution.md +21 -0
- data/docs/SearchCompactResultBlockedScopesInner.md +19 -0
- data/docs/SearchCompactResultOperation.md +21 -0
- data/docs/SearchCompactResultProvider.md +19 -0
- data/docs/SearchEndpointCall.md +1 -1
- data/docs/SearchEndpointCallInputSchema.md +14 -0
- data/docs/SearchEndpointHit.md +5 -1
- data/docs/SearchHelperDocs.md +23 -0
- data/docs/SearchRequest.md +3 -1
- data/docs/SearchResponse.md +9 -3
- data/docs/SearchWeftFetchCompatibility.md +3 -1
- data/docs/WebhooksApi.md +414 -0
- data/lib/weft/client.rb +271 -0
- data/lib/weft/error.rb +41 -0
- data/lib/weft/facilitator/client.rb +179 -42
- data/lib/weft/facilitator/echo.rb +99 -0
- data/lib/weft/facilitator/extensions.rb +183 -0
- data/lib/weft/facilitator/fee.rb +26 -11
- data/lib/weft/facilitator/handshake.rb +92 -0
- data/lib/weft/facilitator/json_wire.rb +74 -0
- data/lib/weft/facilitator/middleware.rb +562 -137
- data/lib/weft/facilitator/path_match.rb +179 -0
- data/lib/weft/facilitator/product.rb +380 -0
- data/lib/weft/facilitator/replay.rb +46 -0
- data/lib/weft/facilitator/settlement.rb +117 -0
- data/lib/weft/facilitator/x402.rb +355 -0
- data/lib/weft/generated/api/account_api.rb +2 -2
- data/lib/weft/generated/api/agent_bootstrap_api.rb +2 -2
- data/lib/weft/generated/api/api_keys_api.rb +2 -2
- data/lib/weft/generated/api/auth_api.rb +2 -2
- data/lib/weft/generated/api/balance_api.rb +2 -2
- data/lib/weft/generated/api/documentation_api.rb +2 -2
- data/lib/weft/generated/api/fetch_api.rb +6 -6
- data/lib/weft/generated/api/payments_api.rb +4 -2
- data/lib/weft/generated/api/purchases_api.rb +4 -4
- data/lib/weft/generated/api/resources_api.rb +2 -2
- data/lib/weft/generated/api/search_api.rb +4 -4
- data/lib/weft/generated/api/webhooks_api.rb +388 -0
- data/lib/weft/generated/api_client.rb +2 -2
- data/lib/weft/generated/api_error.rb +2 -2
- data/lib/weft/generated/api_model_base.rb +2 -2
- data/lib/weft/generated/configuration.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_approval.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_created.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_created_approval.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_created_response.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_request.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_status.rb +2 -2
- data/lib/weft/generated/models/account_bootstrap_status_response.rb +2 -2
- data/lib/weft/generated/models/account_details.rb +2 -2
- data/lib/weft/generated/models/api_key.rb +2 -2
- data/lib/weft/generated/models/api_key_created.rb +2 -2
- data/lib/weft/generated/models/api_key_created_response.rb +2 -2
- data/lib/weft/generated/models/api_key_list_response.rb +2 -2
- data/lib/weft/generated/models/auth_response.rb +2 -2
- data/lib/weft/generated/models/auth_response_data.rb +2 -2
- data/lib/weft/generated/models/balance_response.rb +2 -2
- data/lib/weft/generated/models/confirm_request.rb +2 -2
- data/lib/weft/generated/models/create_api_key_request.rb +2 -2
- data/lib/weft/generated/models/enrolled_resource.rb +2 -2
- data/lib/weft/generated/models/error.rb +2 -2
- data/lib/weft/generated/models/error_response.rb +2 -2
- data/lib/weft/generated/models/fetch403_response.rb +2 -2
- data/lib/weft/generated/models/fetch_balance_snapshot.rb +2 -2
- data/lib/weft/generated/models/fetch_error_response.rb +5 -5
- data/lib/weft/generated/models/fetch_request.rb +46 -5
- data/lib/weft/generated/models/fetch_request_body.rb +2 -2
- data/lib/weft/generated/models/fetch_response.rb +36 -64
- data/lib/weft/generated/models/get_purchase_id_parameter.rb +104 -0
- data/lib/weft/generated/models/insufficient_scope_response.rb +2 -2
- data/lib/weft/generated/models/me_api_key.rb +2 -2
- data/lib/weft/generated/models/me_api_key_creator.rb +2 -2
- data/lib/weft/generated/models/me_response.rb +2 -2
- data/lib/weft/generated/models/me_response_data.rb +2 -2
- data/lib/weft/generated/models/message_response.rb +2 -2
- data/lib/weft/generated/models/message_response_data.rb +2 -2
- data/lib/weft/generated/models/pagination.rb +2 -2
- data/lib/weft/generated/models/password_reset_request.rb +2 -2
- data/lib/weft/generated/models/password_update_request.rb +2 -2
- data/lib/weft/generated/models/payment.rb +2 -2
- data/lib/weft/generated/models/payment_list_response.rb +2 -2
- data/lib/weft/generated/models/payment_response.rb +2 -2
- data/lib/weft/generated/models/principal_wallet.rb +2 -2
- data/lib/weft/generated/models/promo_balance.rb +2 -2
- data/lib/weft/generated/models/purchase.rb +75 -3
- data/lib/weft/generated/models/purchase_artifact.rb +2 -2
- data/lib/weft/generated/models/purchase_list_response.rb +2 -2
- data/lib/weft/generated/models/purchase_response.rb +2 -2
- data/lib/weft/generated/models/rate_limit_response.rb +2 -2
- data/lib/weft/generated/models/resend_confirmation_request.rb +2 -2
- data/lib/weft/generated/models/resource_enrollment_request.rb +2 -2
- data/lib/weft/generated/models/resource_enrollment_response.rb +2 -2
- data/lib/weft/generated/models/resource_stats.rb +2 -2
- data/lib/weft/generated/models/search_access_method.rb +2 -2
- data/lib/weft/generated/models/search_capability_ref.rb +2 -2
- data/lib/weft/generated/models/search_compact_access.rb +179 -0
- data/lib/weft/generated/models/search_compact_access_price.rb +176 -0
- data/lib/weft/generated/models/search_compact_next_call.rb +275 -0
- data/lib/weft/generated/models/search_compact_result.rb +389 -0
- data/lib/weft/generated/models/search_compact_result_attribution.rb +166 -0
- data/lib/weft/generated/models/search_compact_result_blocked_scopes_inner.rb +156 -0
- data/lib/weft/generated/models/search_compact_result_operation.rb +183 -0
- data/lib/weft/generated/models/search_compact_result_provider.rb +156 -0
- data/lib/weft/generated/models/search_curated_callability.rb +2 -2
- data/lib/weft/generated/models/search_curated_execution.rb +2 -2
- data/lib/weft/generated/models/search_curated_operation.rb +2 -2
- data/lib/weft/generated/models/search_curated_service.rb +2 -2
- data/lib/weft/generated/models/search_curated_source.rb +2 -2
- data/lib/weft/generated/models/search_endpoint_call.rb +4 -4
- data/lib/weft/generated/models/search_endpoint_call_input_schema.rb +103 -0
- data/lib/weft/generated/models/search_endpoint_hit.rb +25 -6
- data/lib/weft/generated/models/search_endpoint_price.rb +2 -2
- data/lib/weft/generated/models/search_error_response.rb +4 -4
- data/lib/weft/generated/models/search_execution_mode_filter.rb +2 -2
- data/lib/weft/generated/models/search_filter_spec.rb +2 -2
- data/lib/weft/generated/models/search_helper_docs.rb +175 -0
- data/lib/weft/generated/models/search_method_filter.rb +2 -2
- data/lib/weft/generated/models/search_payment_offer.rb +2 -2
- data/lib/weft/generated/models/search_price_atomic_filter.rb +2 -2
- data/lib/weft/generated/models/search_price_usd_filter.rb +2 -2
- data/lib/weft/generated/models/search_protocol_filter.rb +2 -2
- data/lib/weft/generated/models/search_provider_ref.rb +2 -2
- data/lib/weft/generated/models/search_request.rb +50 -6
- data/lib/weft/generated/models/search_resource_type_filter.rb +2 -2
- data/lib/weft/generated/models/search_response.rb +71 -8
- data/lib/weft/generated/models/search_response_warnings_inner.rb +2 -2
- data/lib/weft/generated/models/search_result.rb +2 -2
- data/lib/weft/generated/models/search_string_set_filter.rb +2 -2
- data/lib/weft/generated/models/search_weft_fetch_compatibility.rb +28 -6
- data/lib/weft/generated/models/sign_in_request.rb +2 -2
- data/lib/weft/generated/models/sign_up_request.rb +2 -2
- data/lib/weft/generated/models/spending_policy.rb +2 -2
- data/lib/weft/generated/models/user.rb +2 -2
- data/lib/weft/generated/models/user_principal.rb +2 -2
- data/lib/weft/generated/models/wallet.rb +2 -2
- data/lib/weft/generated/version.rb +3 -3
- data/lib/weft/sdk.rb +3 -1
- metadata +39 -4
data/lib/weft/error.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
# Normalized buyer API error.
|
|
5
|
+
#
|
|
6
|
+
# The generated OpenAPI model already owns Weft::Error. This exception uses
|
|
7
|
+
# RequestError so the model and the raised error can both exist.
|
|
8
|
+
#
|
|
9
|
+
# `charge` says whether the failed call can have created a charge. 'none'
|
|
10
|
+
# covers this call only: an earlier call under the same idempotency key can
|
|
11
|
+
# still have paid. After 'possible', retry only with the same idempotency key
|
|
12
|
+
# and request.
|
|
13
|
+
class RequestError < StandardError
|
|
14
|
+
# Codes Weft raises before it signs a payment in that call. Same list as the
|
|
15
|
+
# TypeScript reference.
|
|
16
|
+
PRE_SIGN_FETCH_CODES = %w[
|
|
17
|
+
EXCEEDED_MAX_COST MERCHANT_RETURNED_NON_402 INSUFFICIENT_BALANCE
|
|
18
|
+
DENYLISTED_RECIPIENT WALLET_ENVIRONMENT_MISMATCH UNSUPPORTED_ASSET
|
|
19
|
+
INVALID_REQUEST UNKNOWN_PARAMETER INVALID_URL INVALID_MAX_COST_USD
|
|
20
|
+
UNSUPPORTED_METHOD INVALID_BODY INVALID_HEADERS INVALID_IDEMPOTENCY_KEY
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :status, :code, :request_id, :retryable, :details, :charge
|
|
24
|
+
|
|
25
|
+
# @api private
|
|
26
|
+
def self.fetch_charge(status, code)
|
|
27
|
+
pre_sign = PRE_SIGN_FETCH_CODES.include?(code) || code.to_s.start_with?('POLICY_VIOLATION_')
|
|
28
|
+
status.between?(400, 499) && pre_sign ? 'none' : 'possible'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(status:, code:, message:, request_id: nil, retryable: false, details: nil, charge: 'possible')
|
|
32
|
+
super(message)
|
|
33
|
+
@status = status
|
|
34
|
+
@code = code
|
|
35
|
+
@request_id = request_id
|
|
36
|
+
@retryable = retryable
|
|
37
|
+
@details = details
|
|
38
|
+
@charge = charge
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,88 +1,225 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative 'handshake'
|
|
8
|
+
require_relative 'json_wire'
|
|
9
|
+
require_relative 'settlement'
|
|
3
10
|
|
|
4
11
|
module Weft
|
|
5
12
|
module Facilitator
|
|
6
|
-
DEFAULT_URL = 'https://x402.weft.network'
|
|
7
|
-
DEFAULT_ENV = 'X402_FACILITATOR_URL'
|
|
13
|
+
DEFAULT_URL = 'https://x402.weft.network'
|
|
14
|
+
DEFAULT_ENV = 'X402_FACILITATOR_URL'
|
|
15
|
+
PATH_KEYS = %w[verify settle supported bazaar].freeze
|
|
16
|
+
SUPPORTED_RETRIES = 3
|
|
17
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def resolve_url(config = nil)
|
|
22
|
+
url = config_url(config)
|
|
23
|
+
return url unless url.nil? || url.empty?
|
|
24
|
+
|
|
25
|
+
env = ENV.fetch(DEFAULT_ENV, nil)
|
|
26
|
+
return env unless env.nil? || env.empty?
|
|
27
|
+
|
|
28
|
+
DEFAULT_URL
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_url(url)
|
|
32
|
+
if url.nil? || url.strip.empty?
|
|
33
|
+
raise ArgumentError, 'Invalid URL: URL cannot be empty'
|
|
34
|
+
end
|
|
35
|
+
return if url.start_with?('http://') || url.start_with?('https://')
|
|
36
|
+
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"Invalid URL format: URL must start with http:// or https://, got: #{url}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def config_url(config)
|
|
42
|
+
return nil unless config.is_a?(Hash)
|
|
43
|
+
return config['url'] if config.key?('url')
|
|
44
|
+
return config[:url] if config.key?(:url)
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def merge_seller_wins(derived, seller)
|
|
50
|
+
merged = derived.dup
|
|
51
|
+
(seller || {}).each do |name, value|
|
|
52
|
+
merged.delete_if { |existing, _| existing.to_s.casecmp?(name.to_s) }
|
|
53
|
+
merged[name.to_s] = value
|
|
54
|
+
end
|
|
55
|
+
merged
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def assert_path_keyed_auth_headers(headers)
|
|
59
|
+
return unless headers.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
header_object = lambda do |value|
|
|
62
|
+
value.is_a?(Hash)
|
|
63
|
+
end
|
|
64
|
+
has_path_key = PATH_KEYS.any? { |key| header_object.call(headers[key] || headers[key.to_sym]) }
|
|
65
|
+
looks_flat = !has_path_key && headers.values.any? { |value| !header_object.call(value) }
|
|
66
|
+
return unless looks_flat
|
|
67
|
+
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
'createAuthHeaders must return an object keyed by facilitator path, ' \
|
|
70
|
+
'e.g. { verify: { Authorization: "..." }, settle: { ... }, ' \
|
|
71
|
+
'supported: { ... } }, but received a flat headers object.'
|
|
72
|
+
end
|
|
8
73
|
|
|
9
74
|
class Client
|
|
10
|
-
def initialize(url: nil, create_headers: nil)
|
|
11
|
-
|
|
12
|
-
validate_url(
|
|
75
|
+
def initialize(url: nil, create_headers: nil, timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
76
|
+
resolved = Facilitator.resolve_url(url.nil? ? nil : { 'url' => url })
|
|
77
|
+
Facilitator.validate_url(resolved)
|
|
78
|
+
@url = trim_trailing_slashes(resolved)
|
|
13
79
|
@create_headers = create_headers
|
|
80
|
+
@timeout_ms = timeout_ms
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def trim_trailing_slashes(value)
|
|
84
|
+
text = value.dup
|
|
85
|
+
text.chomp!('/') while text.end_with?('/')
|
|
86
|
+
text
|
|
14
87
|
end
|
|
15
88
|
|
|
16
89
|
def verify(payment_payload:, payment_requirements:)
|
|
17
|
-
post_json('/verify', {
|
|
18
|
-
x402Version: 2,
|
|
19
|
-
paymentPayload
|
|
20
|
-
paymentRequirements
|
|
90
|
+
post_json('/verify', 'verify', {
|
|
91
|
+
'x402Version' => payment_payload['x402Version'] || payment_payload[:x402Version] || 2,
|
|
92
|
+
'paymentPayload' => payment_payload,
|
|
93
|
+
'paymentRequirements' => payment_requirements
|
|
21
94
|
})
|
|
22
95
|
end
|
|
23
96
|
|
|
24
97
|
def settle(payment_payload:, payment_requirements:)
|
|
25
|
-
|
|
26
|
-
x402Version: 2,
|
|
27
|
-
paymentPayload: payment_payload,
|
|
28
|
-
paymentRequirements: payment_requirements
|
|
29
|
-
})
|
|
98
|
+
settle_once(payment_payload, payment_requirements, allow_retry: true)
|
|
30
99
|
end
|
|
31
100
|
|
|
32
101
|
def supported
|
|
33
|
-
|
|
102
|
+
last_error = nil
|
|
103
|
+
SUPPORTED_RETRIES.times do |attempt|
|
|
104
|
+
begin
|
|
105
|
+
return get_json('/supported', 'supported')
|
|
106
|
+
rescue RateLimited => e
|
|
107
|
+
last_error = e
|
|
108
|
+
sleep(e.retry_after) if attempt < SUPPORTED_RETRIES - 1
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
raise last_error || StandardError, 'Facilitator getSupported failed'
|
|
34
112
|
end
|
|
35
113
|
|
|
36
114
|
private
|
|
37
115
|
|
|
38
|
-
def
|
|
39
|
-
|
|
116
|
+
def settle_once(payment_payload, payment_requirements, allow_retry:)
|
|
117
|
+
result = post_json('/settle', 'settle', {
|
|
118
|
+
'x402Version' => payment_payload['x402Version'] || payment_payload[:x402Version] || 2,
|
|
119
|
+
'paymentPayload' => payment_payload,
|
|
120
|
+
'paymentRequirements' => payment_requirements
|
|
121
|
+
})
|
|
122
|
+
if allow_retry && pending_result?(result)
|
|
123
|
+
return settle_once(payment_payload, payment_requirements, allow_retry: false)
|
|
124
|
+
end
|
|
40
125
|
|
|
41
|
-
|
|
42
|
-
|
|
126
|
+
result
|
|
127
|
+
rescue SettleError => e
|
|
128
|
+
if allow_retry && pending_error?(e)
|
|
129
|
+
return settle_once(payment_payload, payment_requirements, allow_retry: false)
|
|
130
|
+
end
|
|
131
|
+
raise FacilitatorUnavailableError if SettleErrors.unavailable?(e)
|
|
43
132
|
|
|
44
|
-
|
|
133
|
+
raise
|
|
45
134
|
end
|
|
46
135
|
|
|
47
|
-
def
|
|
48
|
-
|
|
136
|
+
def pending_result?(result)
|
|
137
|
+
result.is_a?(Hash) && result['success'] == false &&
|
|
138
|
+
result['errorReason'] == 'settlement_pending' && !result['transaction'].to_s.empty?
|
|
139
|
+
end
|
|
49
140
|
|
|
50
|
-
|
|
141
|
+
def pending_error?(error)
|
|
142
|
+
error.error_reason == 'settlement_pending' && !error.transaction.to_s.empty?
|
|
51
143
|
end
|
|
52
144
|
|
|
53
|
-
def
|
|
145
|
+
def headers_for(scope)
|
|
54
146
|
headers = { 'Content-Type' => 'application/json' }
|
|
55
147
|
return headers unless @create_headers
|
|
56
148
|
|
|
57
149
|
custom = @create_headers.call
|
|
58
|
-
|
|
150
|
+
Facilitator.assert_path_keyed_auth_headers(custom) if custom
|
|
151
|
+
scope_headers = custom.is_a?(Hash) ? (custom[scope] || custom[scope.to_sym]) : nil
|
|
59
152
|
headers.merge(scope_headers || {})
|
|
60
153
|
end
|
|
61
154
|
|
|
62
|
-
def get_json(path)
|
|
63
|
-
uri = URI.
|
|
64
|
-
request = Net::HTTP::Get.new(uri
|
|
65
|
-
|
|
155
|
+
def get_json(path, scope)
|
|
156
|
+
uri = URI.parse("#{@url}#{path}")
|
|
157
|
+
request = Net::HTTP::Get.new(uri)
|
|
158
|
+
headers_for(scope).each { |name, value| request[name] = value }
|
|
159
|
+
response = perform(uri, request)
|
|
160
|
+
parse_response(response, scope)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def post_json(path, scope, body)
|
|
164
|
+
uri = URI.parse("#{@url}#{path}")
|
|
165
|
+
request = Net::HTTP::Post.new(uri)
|
|
166
|
+
headers_for(scope).each { |name, value| request[name] = value }
|
|
167
|
+
request.body = JsonWire.generate(body)
|
|
168
|
+
response = perform(uri, request)
|
|
169
|
+
parse_response(response, scope)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def perform(uri, request)
|
|
173
|
+
Net::HTTP.start(
|
|
174
|
+
uri.host,
|
|
175
|
+
uri.port,
|
|
176
|
+
use_ssl: uri.scheme == 'https',
|
|
177
|
+
open_timeout: @timeout_ms / 1000.0,
|
|
178
|
+
read_timeout: @timeout_ms / 1000.0
|
|
179
|
+
) do |http|
|
|
66
180
|
http.request(request)
|
|
67
181
|
end
|
|
68
|
-
parse_response(response)
|
|
69
182
|
end
|
|
70
183
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
184
|
+
def parse_response(response, scope)
|
|
185
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
186
|
+
|
|
187
|
+
text = response.body.to_s
|
|
188
|
+
data = begin
|
|
189
|
+
JSON.parse(text)
|
|
190
|
+
rescue JSON::ParserError
|
|
191
|
+
nil
|
|
78
192
|
end
|
|
79
|
-
|
|
193
|
+
if scope == 'settle' && data.is_a?(Hash) && data.key?('success')
|
|
194
|
+
raise SettleError.new(response.code.to_i, data)
|
|
195
|
+
end
|
|
196
|
+
if scope == 'verify' && data.is_a?(Hash) && data.key?('isValid')
|
|
197
|
+
raise StandardError, verify_failure_message(response.code, data)
|
|
198
|
+
end
|
|
199
|
+
raise RateLimited.new(response['retry-after']) if response.code.to_i == 429
|
|
200
|
+
|
|
201
|
+
excerpt = text.length > 200 ? "#{text[0, 200]}..." : text
|
|
202
|
+
raise StandardError, "Facilitator #{scope_name(scope)} failed (#{response.code}): #{excerpt}"
|
|
80
203
|
end
|
|
81
204
|
|
|
82
|
-
def
|
|
83
|
-
|
|
205
|
+
def scope_name(scope)
|
|
206
|
+
scope == 'supported' ? 'getSupported' : scope
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def verify_failure_message(code, data)
|
|
210
|
+
reason = data['invalidReason'] || 'unknown reason'
|
|
211
|
+
message = data['invalidMessage']
|
|
212
|
+
message ? "#{reason}: #{message}" : reason.to_s
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
class RateLimited < StandardError
|
|
217
|
+
attr_reader :retry_after
|
|
84
218
|
|
|
85
|
-
|
|
219
|
+
def initialize(header)
|
|
220
|
+
@retry_after = header.to_i
|
|
221
|
+
@retry_after = 1 if @retry_after <= 0
|
|
222
|
+
super('Facilitator getSupported failed (429)')
|
|
86
223
|
end
|
|
87
224
|
end
|
|
88
225
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
module Facilitator
|
|
5
|
+
module Echo
|
|
6
|
+
ADDITIVE_ARRAY_INFO_FIELDS = { 'builder-code' => ['s'] }.freeze
|
|
7
|
+
ADDITIVE_ARRAY_MAX_LENGTHS = { 'builder-code' => { 's' => 10 } }.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def validate(payment_required, payment, dynamic_fields: {})
|
|
12
|
+
return { 'valid' => true } unless payment.is_a?(Hash) && payment['x402Version'] == 2
|
|
13
|
+
|
|
14
|
+
server_extensions = payment_required['extensions']
|
|
15
|
+
return { 'valid' => true } unless server_extensions.is_a?(Hash) && !server_extensions.empty?
|
|
16
|
+
|
|
17
|
+
client_extensions = payment['extensions']
|
|
18
|
+
return { 'valid' => true } unless client_extensions.is_a?(Hash) && !client_extensions.empty?
|
|
19
|
+
|
|
20
|
+
client_extensions.each do |key, echoed|
|
|
21
|
+
next unless server_extensions.key?(key)
|
|
22
|
+
|
|
23
|
+
fields = dynamic_fields[key]
|
|
24
|
+
advertised = omit_fields(extension_info(server_extensions[key]), fields)
|
|
25
|
+
echoed_info = omit_fields(extension_info(echoed), fields)
|
|
26
|
+
additive = ADDITIVE_ARRAY_INFO_FIELDS[key]
|
|
27
|
+
limits = ADDITIVE_ARRAY_MAX_LENGTHS[key]
|
|
28
|
+
next if contains_subset?(advertised, echoed_info, additive, limits)
|
|
29
|
+
|
|
30
|
+
return { 'valid' => false, 'invalidReason' => 'extension_echo_mismatch', 'extensionKey' => key }
|
|
31
|
+
end
|
|
32
|
+
{ 'valid' => true }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extension_info(value)
|
|
36
|
+
return value['info'] if value.is_a?(Hash) && value.key?('info')
|
|
37
|
+
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def omit_fields(value, fields)
|
|
42
|
+
return value if fields.nil? || fields.empty? || !value.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
copy = value.dup
|
|
45
|
+
fields.each { |field| copy.delete(field) }
|
|
46
|
+
copy
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def contains_subset?(expected, actual, additive = nil, max_lengths = nil, field_key = nil)
|
|
50
|
+
if field_key && additive&.include?(field_key) && (expected.is_a?(Array) || actual.is_a?(Array))
|
|
51
|
+
expected_array = comparable_array(expected)
|
|
52
|
+
actual_array = comparable_array(actual)
|
|
53
|
+
return false unless expected_array && actual_array
|
|
54
|
+
|
|
55
|
+
max = max_lengths && max_lengths[field_key]
|
|
56
|
+
return false if max && actual_array.length > max
|
|
57
|
+
|
|
58
|
+
return expected_array.all? { |item| actual_array.any? { |other| deep_equal?(item, other) } }
|
|
59
|
+
end
|
|
60
|
+
return deep_equal?(expected, actual) if expected.nil? || !expected.is_a?(Hash)
|
|
61
|
+
return false unless actual.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
expected.all? do |key, value|
|
|
64
|
+
if actual.key?(key)
|
|
65
|
+
contains_subset?(value, actual[key], additive, max_lengths, key)
|
|
66
|
+
else
|
|
67
|
+
value.nil?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def comparable_array(value)
|
|
73
|
+
return value if value.is_a?(Array)
|
|
74
|
+
return nil if value.nil? || value.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
[value]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deep_equal?(left, right)
|
|
80
|
+
return true if left.nil? && right.nil?
|
|
81
|
+
return false if left.nil? || right.nil?
|
|
82
|
+
|
|
83
|
+
case left
|
|
84
|
+
when Hash
|
|
85
|
+
return false unless right.is_a?(Hash)
|
|
86
|
+
return false unless left.keys.map(&:to_s).sort == right.keys.map(&:to_s).sort
|
|
87
|
+
|
|
88
|
+
left.all? { |key, value| deep_equal?(value, right[key] || right[key.to_s]) }
|
|
89
|
+
when Array
|
|
90
|
+
return false unless right.is_a?(Array) && left.length == right.length
|
|
91
|
+
|
|
92
|
+
left.zip(right).all? { |item, other| deep_equal?(item, other) }
|
|
93
|
+
else
|
|
94
|
+
left == right
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'json_wire'
|
|
4
|
+
require_relative 'product'
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
module Facilitator
|
|
8
|
+
WEFT_REQUEST_EXTENSION_KEY = 'weft.request'
|
|
9
|
+
MAX_EXTENSION_BYTES = 16 * 1024
|
|
10
|
+
WEFT_REQUEST_INFO_SCHEMA = {
|
|
11
|
+
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
|
|
12
|
+
'title' => 'Weft per-request context',
|
|
13
|
+
'description' => 'Seller-authored context for one paid request, for display only. ' \
|
|
14
|
+
'Unauthenticated buyer input by the time it is read; key nothing on it.',
|
|
15
|
+
'type' => 'object',
|
|
16
|
+
'additionalProperties' => true
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module Extensions
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def dynamic_extension(key, sink = nil)
|
|
23
|
+
sink ||= Product.create_warn
|
|
24
|
+
Hook.new(key, sink)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register_dynamic_extensions(server, routes)
|
|
28
|
+
keys = dynamic_keys(routes)
|
|
29
|
+
return if keys.empty?
|
|
30
|
+
|
|
31
|
+
sink = Product.create_warn
|
|
32
|
+
unless server.respond_to?(:register_extension)
|
|
33
|
+
sink.call(
|
|
34
|
+
'this @x402/core build has no registerExtension, so per-request route ' \
|
|
35
|
+
"extensions (#{keys.join(', ')}) cannot be resolved and will not ship"
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
keys.each { |key| server.register_extension(dynamic_extension(key, sink)) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dynamic_keys(routes)
|
|
44
|
+
keys = []
|
|
45
|
+
route_configs(routes).each do |config|
|
|
46
|
+
extensions = Product.read(config, 'extensions')
|
|
47
|
+
next unless extensions.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
extensions.each do |key, value|
|
|
50
|
+
keys << key.to_s if value.respond_to?(:call)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
keys.uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def route_configs(routes)
|
|
57
|
+
return [] unless routes.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
configs = Product.single_route?(routes) ? [routes] : routes.values
|
|
60
|
+
configs.select { |config| config.is_a?(Hash) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class Hook
|
|
65
|
+
attr_reader :key
|
|
66
|
+
|
|
67
|
+
def initialize(key, sink)
|
|
68
|
+
@key = key
|
|
69
|
+
@sink = sink
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def enrich_payment_required_response(declaration, context)
|
|
73
|
+
return nil unless declaration.respond_to?(:call)
|
|
74
|
+
|
|
75
|
+
request = request_of(context)
|
|
76
|
+
unless request
|
|
77
|
+
@sink.call(
|
|
78
|
+
"extensions[#{@key}] is a callback but no HTTP request context " \
|
|
79
|
+
'reached it; dropping the key from the challenge',
|
|
80
|
+
"#{@key}:no-request"
|
|
81
|
+
)
|
|
82
|
+
return drop_key(context)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
resolved = declaration.call(request)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
@sink.call(
|
|
89
|
+
"extensions[#{@key}] callback failed; dropping the key from the " \
|
|
90
|
+
"challenge: #{e.message}",
|
|
91
|
+
"#{@key}:threw"
|
|
92
|
+
)
|
|
93
|
+
return drop_key(context)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return drop_key(context) if resolved.nil?
|
|
97
|
+
|
|
98
|
+
json = JsonWire.generate(resolved)
|
|
99
|
+
if json.nil?
|
|
100
|
+
@sink.call(
|
|
101
|
+
"extensions[#{@key}] callback returned a value JSON cannot carry (a " \
|
|
102
|
+
'function, a circular reference, a BigInt or similar); dropping ' \
|
|
103
|
+
'the key from the challenge',
|
|
104
|
+
"#{@key}:unserializable"
|
|
105
|
+
)
|
|
106
|
+
return drop_key(context)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
wire = JSON.parse(json)
|
|
110
|
+
unless wire.is_a?(Hash)
|
|
111
|
+
@sink.call(
|
|
112
|
+
"extensions[#{@key}] callback returned #{wire.is_a?(Array) ? 'an array' : "a #{js_type(wire)}"}; " \
|
|
113
|
+
'the x402 extensions channel carries objects, so the key is ' \
|
|
114
|
+
'dropped from the challenge',
|
|
115
|
+
"#{@key}:not-an-object"
|
|
116
|
+
)
|
|
117
|
+
return drop_key(context)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
value = if @key == WEFT_REQUEST_EXTENSION_KEY
|
|
121
|
+
{ 'info' => wire, 'schema' => WEFT_REQUEST_INFO_SCHEMA }
|
|
122
|
+
else
|
|
123
|
+
wire
|
|
124
|
+
end
|
|
125
|
+
bytes = projected_bytes(context, value) || json.bytesize
|
|
126
|
+
if bytes > MAX_EXTENSION_BYTES
|
|
127
|
+
@sink.call(
|
|
128
|
+
"extensions[#{@key}] takes the challenge's extensions to #{bytes} " \
|
|
129
|
+
"bytes, over the #{MAX_EXTENSION_BYTES}-byte facilitator relay " \
|
|
130
|
+
'cap; dropping the key so the rest of the declaration still ' \
|
|
131
|
+
'reaches settlement',
|
|
132
|
+
"#{@key}:over-cap"
|
|
133
|
+
)
|
|
134
|
+
return drop_key(context)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
value
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def request_of(context)
|
|
143
|
+
transport = read_context(context, 'transportContext')
|
|
144
|
+
return nil unless transport.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
Product.read(transport, 'request')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def drop_key(context)
|
|
150
|
+
response = read_context(context, 'paymentRequiredResponse')
|
|
151
|
+
extensions = response.is_a?(Hash) ? Product.read(response, 'extensions') : nil
|
|
152
|
+
if extensions.is_a?(Hash)
|
|
153
|
+
extensions.delete(@key)
|
|
154
|
+
extensions.delete(@key.to_sym)
|
|
155
|
+
end
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def projected_bytes(context, value)
|
|
160
|
+
response = read_context(context, 'paymentRequiredResponse')
|
|
161
|
+
extensions = response.is_a?(Hash) ? Product.read(response, 'extensions') : nil
|
|
162
|
+
projected = Product.stringify_hash(extensions || {})
|
|
163
|
+
projected[@key] = value
|
|
164
|
+
json = JsonWire.generate(projected)
|
|
165
|
+
json&.bytesize
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def read_context(context, key)
|
|
169
|
+
Product.read(context, key)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def js_type(value)
|
|
173
|
+
case value
|
|
174
|
+
when String then 'string'
|
|
175
|
+
when Integer, Float then 'number'
|
|
176
|
+
when TrueClass, FalseClass then 'boolean'
|
|
177
|
+
when nil then 'object'
|
|
178
|
+
else 'object'
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/weft/facilitator/fee.rb
CHANGED
|
@@ -1,27 +1,35 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
require_relative 'client'
|
|
4
|
+
|
|
1
5
|
module Weft
|
|
2
6
|
module Facilitator
|
|
3
7
|
module Fee
|
|
4
|
-
|
|
8
|
+
DEFAULT_TTL_MS = 5 * 60 * 1000
|
|
5
9
|
|
|
6
10
|
@cache = nil
|
|
7
11
|
|
|
8
12
|
class << self
|
|
9
|
-
def get_fee_info(client: Client.new, ttl:
|
|
13
|
+
def get_fee_info(client: Client.new, ttl: DEFAULT_TTL_MS)
|
|
10
14
|
return @cache[:fee] if cache_valid?
|
|
11
15
|
|
|
12
16
|
supported = client.supported
|
|
13
|
-
fee = supported['fee']
|
|
17
|
+
fee = supported['fee'] || supported[:fee]
|
|
14
18
|
raise 'Fee information not found in /supported response' unless fee
|
|
15
19
|
|
|
16
20
|
validate_fee!(fee)
|
|
21
|
+
normalized = {
|
|
22
|
+
'amount' => fee['amount'] || fee[:amount],
|
|
23
|
+
'asset' => fee['asset'] || fee[:asset],
|
|
24
|
+
'network' => fee['network'] || fee[:network]
|
|
25
|
+
}
|
|
17
26
|
|
|
18
27
|
@cache = {
|
|
19
|
-
fee:
|
|
20
|
-
fetched_at:
|
|
28
|
+
fee: normalized,
|
|
29
|
+
fetched_at: now_ms,
|
|
21
30
|
ttl: ttl
|
|
22
31
|
}
|
|
23
|
-
|
|
24
|
-
fee
|
|
32
|
+
normalized
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def invalidate_fee_cache
|
|
@@ -33,13 +41,20 @@ module Weft
|
|
|
33
41
|
def cache_valid?
|
|
34
42
|
return false unless @cache
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
now_ms - @cache[:fetched_at] < @cache[:ttl]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def now_ms
|
|
48
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
def validate_fee!(fee)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
amount = fee['amount'] || fee[:amount]
|
|
53
|
+
asset = fee['asset'] || fee[:asset]
|
|
54
|
+
network = fee['network'] || fee[:network]
|
|
55
|
+
raise 'Invalid fee structure: amount must be a string' unless amount.is_a?(String)
|
|
56
|
+
raise 'Invalid fee structure: asset must be a string' unless asset.is_a?(String)
|
|
57
|
+
raise 'Invalid fee structure: network must be a string' unless network.is_a?(String)
|
|
43
58
|
end
|
|
44
59
|
end
|
|
45
60
|
end
|