weft-sdk 0.24.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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/docs/FetchApi.md +3 -3
  3. data/docs/FetchErrorResponse.md +1 -1
  4. data/docs/FetchRequest.md +6 -2
  5. data/docs/FetchResponse.md +4 -4
  6. data/docs/GetPurchaseIdParameter.md +48 -0
  7. data/docs/PaymentsApi.md +2 -0
  8. data/docs/Purchase.md +6 -0
  9. data/docs/PurchasesApi.md +2 -2
  10. data/docs/SearchApi.md +1 -68
  11. data/docs/SearchCompactAccess.md +23 -0
  12. data/docs/SearchCompactAccessPrice.md +23 -0
  13. data/docs/SearchCompactNextCall.md +23 -0
  14. data/docs/SearchCompactResult.md +47 -0
  15. data/docs/SearchCompactResultAttribution.md +21 -0
  16. data/docs/SearchCompactResultBlockedScopesInner.md +19 -0
  17. data/docs/SearchCompactResultOperation.md +21 -0
  18. data/docs/SearchCompactResultProvider.md +19 -0
  19. data/docs/SearchEndpointCall.md +1 -1
  20. data/docs/SearchEndpointCallInputSchema.md +14 -0
  21. data/docs/SearchEndpointHit.md +5 -1
  22. data/docs/SearchHelperDocs.md +23 -0
  23. data/docs/SearchRequest.md +3 -1
  24. data/docs/SearchResponse.md +9 -3
  25. data/docs/SearchWeftFetchCompatibility.md +3 -1
  26. data/docs/WebhooksApi.md +414 -0
  27. data/lib/weft/client.rb +271 -0
  28. data/lib/weft/error.rb +41 -0
  29. data/lib/weft/facilitator/client.rb +179 -42
  30. data/lib/weft/facilitator/echo.rb +99 -0
  31. data/lib/weft/facilitator/extensions.rb +183 -0
  32. data/lib/weft/facilitator/fee.rb +26 -11
  33. data/lib/weft/facilitator/handshake.rb +92 -0
  34. data/lib/weft/facilitator/json_wire.rb +74 -0
  35. data/lib/weft/facilitator/middleware.rb +562 -137
  36. data/lib/weft/facilitator/path_match.rb +179 -0
  37. data/lib/weft/facilitator/product.rb +380 -0
  38. data/lib/weft/facilitator/replay.rb +46 -0
  39. data/lib/weft/facilitator/settlement.rb +117 -0
  40. data/lib/weft/facilitator/x402.rb +355 -0
  41. data/lib/weft/generated/api/account_api.rb +2 -2
  42. data/lib/weft/generated/api/agent_bootstrap_api.rb +2 -2
  43. data/lib/weft/generated/api/api_keys_api.rb +2 -2
  44. data/lib/weft/generated/api/auth_api.rb +2 -2
  45. data/lib/weft/generated/api/balance_api.rb +2 -2
  46. data/lib/weft/generated/api/documentation_api.rb +2 -2
  47. data/lib/weft/generated/api/fetch_api.rb +6 -6
  48. data/lib/weft/generated/api/payments_api.rb +4 -2
  49. data/lib/weft/generated/api/purchases_api.rb +4 -4
  50. data/lib/weft/generated/api/resources_api.rb +2 -2
  51. data/lib/weft/generated/api/search_api.rb +4 -83
  52. data/lib/weft/generated/api/webhooks_api.rb +388 -0
  53. data/lib/weft/generated/api_client.rb +2 -2
  54. data/lib/weft/generated/api_error.rb +2 -2
  55. data/lib/weft/generated/api_model_base.rb +2 -2
  56. data/lib/weft/generated/configuration.rb +2 -2
  57. data/lib/weft/generated/models/account_bootstrap_approval.rb +2 -2
  58. data/lib/weft/generated/models/account_bootstrap_created.rb +2 -2
  59. data/lib/weft/generated/models/account_bootstrap_created_approval.rb +2 -2
  60. data/lib/weft/generated/models/account_bootstrap_created_response.rb +2 -2
  61. data/lib/weft/generated/models/account_bootstrap_request.rb +2 -2
  62. data/lib/weft/generated/models/account_bootstrap_status.rb +2 -2
  63. data/lib/weft/generated/models/account_bootstrap_status_response.rb +2 -2
  64. data/lib/weft/generated/models/account_details.rb +2 -2
  65. data/lib/weft/generated/models/api_key.rb +2 -2
  66. data/lib/weft/generated/models/api_key_created.rb +2 -2
  67. data/lib/weft/generated/models/api_key_created_response.rb +2 -2
  68. data/lib/weft/generated/models/api_key_list_response.rb +2 -2
  69. data/lib/weft/generated/models/auth_response.rb +2 -2
  70. data/lib/weft/generated/models/auth_response_data.rb +2 -2
  71. data/lib/weft/generated/models/balance_response.rb +2 -2
  72. data/lib/weft/generated/models/confirm_request.rb +2 -2
  73. data/lib/weft/generated/models/create_api_key_request.rb +2 -2
  74. data/lib/weft/generated/models/enrolled_resource.rb +2 -2
  75. data/lib/weft/generated/models/error.rb +2 -2
  76. data/lib/weft/generated/models/error_response.rb +2 -2
  77. data/lib/weft/generated/models/fetch403_response.rb +2 -2
  78. data/lib/weft/generated/models/fetch_balance_snapshot.rb +2 -2
  79. data/lib/weft/generated/models/fetch_error_response.rb +5 -5
  80. data/lib/weft/generated/models/fetch_request.rb +46 -5
  81. data/lib/weft/generated/models/fetch_request_body.rb +2 -2
  82. data/lib/weft/generated/models/fetch_response.rb +30 -85
  83. data/lib/weft/generated/models/get_purchase_id_parameter.rb +104 -0
  84. data/lib/weft/generated/models/insufficient_scope_response.rb +2 -2
  85. data/lib/weft/generated/models/me_api_key.rb +2 -2
  86. data/lib/weft/generated/models/me_api_key_creator.rb +2 -2
  87. data/lib/weft/generated/models/me_response.rb +2 -2
  88. data/lib/weft/generated/models/me_response_data.rb +2 -2
  89. data/lib/weft/generated/models/message_response.rb +2 -2
  90. data/lib/weft/generated/models/message_response_data.rb +2 -2
  91. data/lib/weft/generated/models/pagination.rb +2 -2
  92. data/lib/weft/generated/models/password_reset_request.rb +2 -2
  93. data/lib/weft/generated/models/password_update_request.rb +2 -2
  94. data/lib/weft/generated/models/payment.rb +2 -2
  95. data/lib/weft/generated/models/payment_list_response.rb +2 -2
  96. data/lib/weft/generated/models/payment_response.rb +2 -2
  97. data/lib/weft/generated/models/principal_wallet.rb +2 -2
  98. data/lib/weft/generated/models/promo_balance.rb +2 -2
  99. data/lib/weft/generated/models/purchase.rb +75 -3
  100. data/lib/weft/generated/models/purchase_artifact.rb +2 -2
  101. data/lib/weft/generated/models/purchase_list_response.rb +2 -2
  102. data/lib/weft/generated/models/purchase_response.rb +2 -2
  103. data/lib/weft/generated/models/rate_limit_response.rb +2 -2
  104. data/lib/weft/generated/models/resend_confirmation_request.rb +2 -2
  105. data/lib/weft/generated/models/resource_enrollment_request.rb +2 -2
  106. data/lib/weft/generated/models/resource_enrollment_response.rb +2 -2
  107. data/lib/weft/generated/models/resource_stats.rb +2 -2
  108. data/lib/weft/generated/models/search_access_method.rb +2 -2
  109. data/lib/weft/generated/models/search_capability_ref.rb +2 -2
  110. data/lib/weft/generated/models/search_compact_access.rb +179 -0
  111. data/lib/weft/generated/models/search_compact_access_price.rb +176 -0
  112. data/lib/weft/generated/models/search_compact_next_call.rb +275 -0
  113. data/lib/weft/generated/models/search_compact_result.rb +389 -0
  114. data/lib/weft/generated/models/search_compact_result_attribution.rb +166 -0
  115. data/lib/weft/generated/models/search_compact_result_blocked_scopes_inner.rb +156 -0
  116. data/lib/weft/generated/models/search_compact_result_operation.rb +183 -0
  117. data/lib/weft/generated/models/search_compact_result_provider.rb +156 -0
  118. data/lib/weft/generated/models/search_curated_callability.rb +2 -2
  119. data/lib/weft/generated/models/search_curated_execution.rb +2 -2
  120. data/lib/weft/generated/models/search_curated_operation.rb +2 -2
  121. data/lib/weft/generated/models/search_curated_service.rb +2 -2
  122. data/lib/weft/generated/models/search_curated_source.rb +2 -2
  123. data/lib/weft/generated/models/search_endpoint_call.rb +4 -4
  124. data/lib/weft/generated/models/search_endpoint_call_input_schema.rb +103 -0
  125. data/lib/weft/generated/models/search_endpoint_hit.rb +25 -6
  126. data/lib/weft/generated/models/search_endpoint_price.rb +2 -2
  127. data/lib/weft/generated/models/search_error_response.rb +4 -4
  128. data/lib/weft/generated/models/search_execution_mode_filter.rb +2 -2
  129. data/lib/weft/generated/models/search_filter_spec.rb +2 -2
  130. data/lib/weft/generated/models/search_helper_docs.rb +175 -0
  131. data/lib/weft/generated/models/search_method_filter.rb +2 -2
  132. data/lib/weft/generated/models/search_payment_offer.rb +2 -2
  133. data/lib/weft/generated/models/search_price_atomic_filter.rb +2 -2
  134. data/lib/weft/generated/models/search_price_usd_filter.rb +2 -2
  135. data/lib/weft/generated/models/search_protocol_filter.rb +2 -2
  136. data/lib/weft/generated/models/search_provider_ref.rb +2 -2
  137. data/lib/weft/generated/models/search_request.rb +50 -6
  138. data/lib/weft/generated/models/search_resource_type_filter.rb +2 -2
  139. data/lib/weft/generated/models/search_response.rb +71 -8
  140. data/lib/weft/generated/models/search_response_warnings_inner.rb +2 -2
  141. data/lib/weft/generated/models/search_result.rb +2 -2
  142. data/lib/weft/generated/models/search_string_set_filter.rb +2 -2
  143. data/lib/weft/generated/models/search_weft_fetch_compatibility.rb +28 -6
  144. data/lib/weft/generated/models/sign_in_request.rb +2 -2
  145. data/lib/weft/generated/models/sign_up_request.rb +2 -2
  146. data/lib/weft/generated/models/spending_policy.rb +2 -2
  147. data/lib/weft/generated/models/user.rb +2 -2
  148. data/lib/weft/generated/models/user_principal.rb +2 -2
  149. data/lib/weft/generated/models/wallet.rb +2 -2
  150. data/lib/weft/generated/version.rb +3 -3
  151. data/lib/weft/sdk.rb +3 -1
  152. metadata +39 -8
  153. data/docs/CuratedMarketplaceContract.md +0 -31
  154. data/docs/Merchant.md +0 -23
  155. data/lib/weft/generated/models/curated_marketplace_contract.rb +0 -384
  156. data/lib/weft/generated/models/merchant.rb +0 -264
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'error'
6
+ require_relative 'generated'
7
+
8
+ module Weft
9
+ # Buyer façade over the generated API client.
10
+ class Client
11
+ def initialize(api_key: nil, access_token: nil, base_url: 'https://weft.network')
12
+ if !api_key.nil? && !access_token.nil?
13
+ raise ArgumentError, 'api_key and access_token are mutually exclusive'
14
+ end
15
+
16
+ raw = api_key.nil? ? access_token : api_key
17
+ credential = raw.is_a?(String) ? raw.strip : ''
18
+ if credential.empty?
19
+ raise ArgumentError, credential_error(api_key, access_token)
20
+ end
21
+
22
+ configuration = Configuration.new
23
+ apply_base_url(configuration, base_url)
24
+ configuration.access_token = credential
25
+ @api_client = ApiClient.new(configuration)
26
+ @account = AccountApi.new(@api_client)
27
+ @balance = BalanceApi.new(@api_client)
28
+ @search = SearchApi.new(@api_client)
29
+ @fetch = FetchApi.new(@api_client)
30
+ @purchases = PurchasesApi.new(@api_client)
31
+ end
32
+
33
+ def me
34
+ call { @account.get_me }
35
+ end
36
+
37
+ def balance
38
+ call { @balance.get_balance }
39
+ end
40
+
41
+ def search(query:, max_results: nil, filters: nil)
42
+ request = SearchRequest.new(query: query)
43
+ if max_results.nil?
44
+ request.remove_instance_variable(:@max_results)
45
+ else
46
+ request.max_results = max_results
47
+ end
48
+ request.filters = search_filters(filters) unless filters.nil?
49
+ call { @search.search(request) }
50
+ end
51
+
52
+ def fetch(url:, max_cost_usd:, idempotency_key:, method: nil, body: nil, headers: nil,
53
+ search_id: nil, operation_id: nil, access_method_id: nil)
54
+ raise ArgumentError, 'max_cost_usd is required' if max_cost_usd.to_s.strip.empty?
55
+ raise ArgumentError, 'idempotency_key is required' if idempotency_key.to_s.strip.empty?
56
+ # Weft's limit. Checked here so a bad key never looks like a paid failure.
57
+ unless idempotency_key.to_s.match?(/\A[!-~]{1,255}\z/)
58
+ raise ArgumentError, 'idempotency_key must be 1-255 visible ASCII characters without spaces'
59
+ end
60
+
61
+ fields = {
62
+ url: url,
63
+ max_cost_usd: max_cost_usd,
64
+ method: method.nil? ? nil : method.to_s.upcase
65
+ }
66
+ fields[:body] = fetch_body(body) unless body.nil?
67
+ fields[:headers] = headers.transform_keys(&:to_s) unless headers.nil?
68
+ fields[:search_id] = search_id unless search_id.nil?
69
+ fields[:operation_id] = operation_id unless operation_id.nil?
70
+ fields[:access_method_id] = access_method_id unless access_method_id.nil?
71
+ request = FetchRequest.new(fields)
72
+ response = call(paid: true) { @fetch.fetch(request, idempotency_key: idempotency_key) }
73
+ # A 2xx without a fetch result body: the fetch most likely paid.
74
+ raise decode_error if response.nil?
75
+
76
+ response
77
+ end
78
+
79
+ def purchases(page: nil, per_page: nil)
80
+ call { @purchases.list_purchases(page: page, per_page: per_page) }
81
+ end
82
+
83
+ def purchase(id)
84
+ call { @purchases.get_purchase(id) }
85
+ end
86
+
87
+ private
88
+
89
+ def call(paid: false)
90
+ yield
91
+ rescue ApiError => e
92
+ raise normalize_api_error(e, paid: paid)
93
+ rescue StandardError => e
94
+ raise if !paid || e.is_a?(RequestError)
95
+
96
+ # Weft answered 2xx, so the fetch most likely paid, but the body did not decode.
97
+ raise decode_error
98
+ end
99
+
100
+ # A replay returns the same body; reconcile instead of retrying.
101
+ def decode_error
102
+ RequestError.new(
103
+ status: 0,
104
+ code: 'RESPONSE_DECODE_ERROR',
105
+ message: 'Weft API returned a fetch response that could not be decoded',
106
+ retryable: false,
107
+ charge: 'possible'
108
+ )
109
+ end
110
+
111
+ def credential_error(api_key, access_token)
112
+ return 'api_key is required' unless api_key.nil?
113
+ return 'access_token is required' unless access_token.nil?
114
+
115
+ 'api_key or access_token is required'
116
+ end
117
+
118
+ def apply_base_url(configuration, base_url)
119
+ stripped = trim_trailing_slashes(base_url.to_s)
120
+ uri = URI.parse(stripped)
121
+ configuration.scheme = uri.scheme || 'https'
122
+ configuration.host = uri.host || trim_trailing_slashes(stripped)
123
+ path = collapse_slashes(uri.path.to_s)
124
+ path = trim_trailing_slashes(path)
125
+ path = strip_leading_slashes(path)
126
+ configuration.base_path = path
127
+ end
128
+
129
+ def trim_trailing_slashes(value)
130
+ text = value.dup
131
+ text.chomp!('/') while text.end_with?('/')
132
+ text
133
+ end
134
+
135
+ def strip_leading_slashes(value)
136
+ index = 0
137
+ index += 1 while index < value.length && value[index] == '/'
138
+ index.zero? ? value : value[index..]
139
+ end
140
+
141
+ def collapse_slashes(value)
142
+ out = +''
143
+ previous_slash = false
144
+ value.each_char do |char|
145
+ if char == '/'
146
+ out << '/' unless previous_slash
147
+ previous_slash = true
148
+ else
149
+ out << char
150
+ previous_slash = false
151
+ end
152
+ end
153
+ out
154
+ end
155
+
156
+ def search_filters(value)
157
+ mapped = map_filters(value)
158
+ spec = SearchFilterSpec.new(mapped)
159
+ spec.include_unknown_prices = nil unless mapped.key?(:include_unknown_prices)
160
+ spec
161
+ end
162
+
163
+ def map_filters(value)
164
+ table = {
165
+ 'price' => :price,
166
+ 'priceAtomic' => :price_atomic,
167
+ 'type' => :type,
168
+ 'protocol' => :protocol,
169
+ 'category' => :category,
170
+ 'method' => :method,
171
+ 'executionMode' => :execution_mode,
172
+ 'weftFetchCompatible' => :weft_fetch_compatible,
173
+ 'includeUnknownPrices' => :include_unknown_prices
174
+ }
175
+ nested = %i[price price_atomic type protocol category method execution_mode]
176
+ operators = {
177
+ 'lte' => :lte,
178
+ 'gte' => :gte,
179
+ 'eq' => :eq,
180
+ 'in' => :in,
181
+ 'rangeGte' => :range_gte,
182
+ 'rangeLte' => :range_lte
183
+ }
184
+ mapped = map_keys(value, table)
185
+ nested.each do |name|
186
+ child = mapped[name]
187
+ next unless child.is_a?(Hash)
188
+
189
+ mapped[name] = map_keys(child, operators)
190
+ end
191
+ mapped
192
+ end
193
+
194
+ def map_keys(source, table)
195
+ source.each_with_object({}) do |(key, item), out|
196
+ mapped = table[key.to_s] || table[key.to_sym]
197
+ raise ArgumentError, "unmapped field #{key}" if mapped.nil?
198
+
199
+ out[mapped] = item
200
+ end
201
+ end
202
+
203
+ def fetch_body(value)
204
+ return value if value.is_a?(String)
205
+
206
+ JSON.generate(json_null_non_finite(value))
207
+ end
208
+
209
+ def json_null_non_finite(value)
210
+ case value
211
+ when Float
212
+ value.finite? ? value : nil
213
+ when Hash
214
+ value.each_with_object({}) { |(key, item), out| out[key] = json_null_non_finite(item) }
215
+ when Array
216
+ value.map { |item| json_null_non_finite(item) }
217
+ else
218
+ value
219
+ end
220
+ end
221
+
222
+ def normalize_api_error(error, paid:)
223
+ if error.code.to_i.zero? && !error.response_body
224
+ cause = error.instance_variable_get(:@message).to_s
225
+ cause = 'connection reset' if cause.empty?
226
+ return RequestError.new(
227
+ status: 0,
228
+ code: 'NETWORK_ERROR',
229
+ message: "Network failure before a Weft API response: #{cause}",
230
+ request_id: nil,
231
+ retryable: true,
232
+ details: nil,
233
+ charge: paid ? 'possible' : 'none'
234
+ )
235
+ end
236
+
237
+ details = parse_details(error.response_body)
238
+ body = details.is_a?(Hash) ? details : nil
239
+ nested = body && body['error'].is_a?(Hash) ? body['error'] : nil
240
+ status = error.code.to_i
241
+ code = nested&.[]('code') || body&.[]('code') || (body && body['error'].is_a?(String) ? body['error'] : "HTTP_#{status}")
242
+ RequestError.new(
243
+ status: status,
244
+ code: code,
245
+ message: nested&.[]('message') || body&.[]('message') || "Weft API returned HTTP #{status}",
246
+ request_id: nested&.[]('request_id') || body&.[]('request_id') || header_request_id(error),
247
+ retryable: status == 429 || status >= 500,
248
+ details: details,
249
+ charge: paid ? RequestError.fetch_charge(status, code) : 'none'
250
+ )
251
+ end
252
+
253
+ def parse_details(body)
254
+ return nil if body.nil? || body.empty?
255
+
256
+ parsed = JSON.parse(body)
257
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
258
+ rescue JSON::ParserError
259
+ nil
260
+ end
261
+
262
+ def header_request_id(error)
263
+ headers = error.response_headers
264
+ return nil unless headers.respond_to?(:each)
265
+
266
+ pair = headers.find { |name, _| name.to_s.casecmp?('x-request-id') }
267
+ value = pair&.last
268
+ value.is_a?(Array) ? value.first : value
269
+ end
270
+ end
271
+ end
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
- require 'net/http'
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'.freeze
7
- DEFAULT_ENV = 'X402_FACILITATOR_URL'.freeze
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
- @url = resolve_url(url)
12
- validate_url(@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: payment_payload,
20
- paymentRequirements: payment_requirements
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
- post_json('/settle', {
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
- get_json('/supported')
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 resolve_url(url)
39
- return url if url && !url.empty?
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
- env = ENV.fetch(DEFAULT_ENV, nil)
42
- return env if env && !env.empty?
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
- DEFAULT_URL
133
+ raise
45
134
  end
46
135
 
47
- def validate_url(url)
48
- return unless url.empty? || (!url.start_with?('http://') && !url.start_with?('https://'))
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
- raise ArgumentError, "Invalid URL: #{url}"
141
+ def pending_error?(error)
142
+ error.error_reason == 'settlement_pending' && !error.transaction.to_s.empty?
51
143
  end
52
144
 
53
- def build_headers(scope)
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
- scope_headers = custom && custom[scope]
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.join(@url, path)
64
- request = Net::HTTP::Get.new(uri, build_headers('supported'))
65
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
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 post_json(path, body)
72
- uri = URI.join(@url, path)
73
- scope = path.include?('verify') ? 'verify' : 'settle'
74
- request = Net::HTTP::Post.new(uri, build_headers(scope))
75
- request.body = JSON.generate(body)
76
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
77
- http.request(request)
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
- parse_response(response)
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 parse_response(response)
83
- raise "Facilitator request failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
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
- JSON.parse(response.body)
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