blockchain0x 0.0.1.alpha.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ef9ababb8a0e13928f13336087fc2fb9ea8762aeb4f18077f3574e1cddf491db
4
+ data.tar.gz: 833a4fab94699de66bc561c011cf4a9ceebf77a692175ea7b3594ffce11c5656
5
+ SHA512:
6
+ metadata.gz: f7646672e245cdbaebb3fcd45bbc1d216bdaa1538b7c58763f4d860a36413d851e498fb677ec8df6e5a081931b5f5a2802511e340b5b525718ed9c3bd3e8236e
7
+ data.tar.gz: 7a9c2d95eb3c8f5b1ea1fa7e5192a0528f3bc4b23eca2529e46ec6f22a782578dd035af4cf671efc9b5a23c5722a68fb6c70a8f9d0aee9a0ad0c7a0ef0c3d491
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for describing the origin of the Work and
141
+ reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Support. While redistributing the Work or
166
+ Derivative Works thereof, You may choose to offer, and charge a
167
+ fee for, acceptance of support, warranty, indemnity, or other
168
+ liability obligations and/or rights consistent with this License.
169
+ However, in accepting such obligations, You may act only on Your
170
+ own behalf and on Your sole responsibility, not on behalf of any
171
+ other Contributor, and only if You agree to indemnify, defend,
172
+ and hold each Contributor harmless for any liability incurred by,
173
+ or claims asserted against, such Contributor by reason of your
174
+ accepting any such warranty or support.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Tosh Labs
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
200
+ implied. See the License for the specific language governing
201
+ permissions and limitations under the License.
data/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # blockchain0x
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/blockchain0x.svg)](https://rubygems.org/gems/blockchain0x)
4
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
5
+ [![Ruby ≥ 3.0](https://img.shields.io/badge/ruby-%E2%89%A53.0-brightgreen.svg)](#requirements)
6
+
7
+ **Official Ruby SDK for [Blockchain0x](https://blockchain0x.com)** -
8
+ the non-custodial AI-agent wallet platform on Base.
9
+
10
+ > Pre-release: `0.0.1.alpha.0` ships the operational essentials -
11
+ > HTTP transport, `api_keys` resource, and `Webhooks.verify`. The full
12
+ > surface (every resource + x402 client) lands in sub-plan 21.3 Phase
13
+ > C follow-up rows.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ gem install blockchain0x --pre
19
+ ```
20
+
21
+ Or in a `Gemfile`:
22
+
23
+ ```ruby
24
+ gem 'blockchain0x', '~> 0.0.1.alpha'
25
+ ```
26
+
27
+ Requires Ruby 3.0 or newer.
28
+
29
+ ## Quick start
30
+
31
+ ```ruby
32
+ require 'blockchain0x'
33
+
34
+ client = Blockchain0x::Client.new(api_key: ENV.fetch('BLOCKCHAIN0X_API_KEY'))
35
+ page = client.api_keys.list
36
+ page['data'].each { |k| puts "#{k['id']}\t#{k['prefix']}" }
37
+ ```
38
+
39
+ The client pins the network from the API-key prefix (`sk_test_*` →
40
+ testnet, `sk_live_*` → mainnet). Override with
41
+ `Blockchain0x::Client.new(api_key: ..., network: 'mainnet')` when
42
+ running mixed-mode tests.
43
+
44
+ ## Verify webhook signatures
45
+
46
+ The single most important utility this SDK ships - drop it into the
47
+ top of your webhook controller BEFORE touching the body.
48
+
49
+ ```ruby
50
+ # Rails example. Adapt for Sinatra / Hanami / Roda - the only
51
+ # requirement is the raw request body, NOT a parsed JSON hash.
52
+ class WebhooksController < ApplicationController
53
+ skip_before_action :verify_authenticity_token, only: :receive
54
+
55
+ def receive
56
+ result = Blockchain0x::Webhooks.verify(
57
+ headers: request.headers,
58
+ raw_body: request.raw_post,
59
+ secret: ENV.fetch('BLOCKCHAIN0X_WEBHOOK_SECRET'),
60
+ )
61
+ return head(:bad_request) unless result.ok?
62
+
63
+ # result.event_type / result.event_id / result.delivery_id populated.
64
+ Sidekiq.redis do |r|
65
+ r.lpush("events:#{result.event_type}", request.raw_post)
66
+ end
67
+ head :no_content
68
+ end
69
+ end
70
+ ```
71
+
72
+ The verifier:
73
+
74
+ - Reads `X-Blockchain0x-Signature` in either `t=<ts>,v1=<hex>` or
75
+ bare-hex form (some load balancers strip commas).
76
+ - Falls back to `X-Blockchain0x-Timestamp` when the signature is bare.
77
+ - Rejects with `webhook.timestamp_outside_window` when drift exceeds
78
+ 300 seconds.
79
+ - Constant-time compares via `OpenSSL.fixed_length_secure_compare`
80
+ (or a manual byte-XOR fallback for older OpenSSL builds).
81
+
82
+ For exception-based flows pass `raise_on_fail: true`:
83
+
84
+ ```ruby
85
+ begin
86
+ result = Blockchain0x::Webhooks.verify(
87
+ headers: request.headers,
88
+ raw_body: request.raw_post,
89
+ secret: ENV.fetch('BLOCKCHAIN0X_WEBHOOK_SECRET'),
90
+ raise_on_fail: true,
91
+ )
92
+ rescue Blockchain0x::WebhookSignatureError => e
93
+ return render json: { code: e.code }, status: :bad_request
94
+ end
95
+ ```
96
+
97
+ ## Errors
98
+
99
+ Two classes:
100
+
101
+ - `Blockchain0x::Error` - base class; every SDK error inherits.
102
+ - `Blockchain0x::APIKeyError` - subclass for HTTP 401 / 403 envelopes
103
+ whose `error.code` starts with `apikey.` (e.g.
104
+ `apikey.scope_insufficient`, `apikey.wallet_not_assigned`).
105
+
106
+ Always branch on `.code`, never regex-match `.message`:
107
+
108
+ ```ruby
109
+ begin
110
+ client.api_keys.list
111
+ rescue Blockchain0x::APIKeyError => e
112
+ if e.code == 'apikey.scope_insufficient'
113
+ # mint a fresh key with more scope
114
+ end
115
+ end
116
+ ```
117
+
118
+ The module helper `Blockchain0x.apikey_error?(error)` returns true
119
+ when the exception's code begins with `apikey.` regardless of
120
+ subclass.
121
+
122
+ ## Retry behaviour
123
+
124
+ The transport retries on `429` and `5xx` with exponential backoff
125
+ (0.5s → 1s → 2s → … → 30s cap, 3 retries by default).
126
+ `Retry-After` is honoured when the server sends it.
127
+
128
+ `POST` / `PATCH` / `DELETE` requests carry an `Idempotency-Key`
129
+ header - the SDK mints a UUID v4 if you do not supply one. Pass
130
+ `idempotency_key: '...'` to thread a stable key across SDK retries OR
131
+ across processes (e.g. a cron job that hashes its input
132
+ deterministically).
133
+
134
+ ## Workspace keys (sub-plan 21.3)
135
+
136
+ Two key shapes exist (see
137
+ [docs/concept-api-key-types.md](https://github.com/Tosh-Labs/blockchain0x-app/blob/dev/docs/concept-api-key-types.md)
138
+ for the full decision tree):
139
+
140
+ - **Wallet-only** - bound to ONE agent. Right shape for an autonomous AI agent that IS one wallet.
141
+ - **Workspace** - human-operator key that can carry workspace-level scopes AND assignments to N specific wallets.
142
+
143
+ The Ruby SDK forwards both shapes through `client.api_keys`. Once
144
+ the C-2-style `create` method lands beyond the alpha scaffold:
145
+
146
+ ```ruby
147
+ key = client.api_keys.create(
148
+ label: 'Treasury reconciliation',
149
+ workspace_scopes: ['read_workspace'],
150
+ wallet_assignments: [
151
+ { agent_id: 'agt_trading', scopes: ['read_wallet_metadata'] },
152
+ { agent_id: 'agt_settlement', scopes: ['read_wallet_metadata'] },
153
+ ],
154
+ expires_in_days: 30,
155
+ )
156
+ puts key['secret'] # shown ONCE
157
+ ```
158
+
159
+ Server-side RBAC: the minter cannot grant a scope they do not have
160
+ themselves. Over-grants reject with `apikey.role_insufficient_for_grants`
161
+ which is surfaced as a `Blockchain0x::APIKeyError`.
162
+
163
+ ## x402 (Phase C-7)
164
+
165
+ The sibling gem `blockchain0x-x402` (Ruby port of
166
+ [`@blockchain0x/x402`](https://www.npmjs.com/package/@blockchain0x/x402))
167
+ will ship the x402 client + Rack middleware in sub-plan 21.3 row C-7.
168
+ The wire format is identical across languages so a Ruby service can
169
+ accept payments from a Node client and vice-versa.
170
+
171
+ ## Codegen
172
+
173
+ Model classes are generated from
174
+ `apps/backend/openapi/openapi.yaml` via
175
+ [openapi-generator-cli](https://github.com/OpenAPITools/openapi-generator)
176
+ with `-g ruby --global-property=models,supportingFiles=false` - see
177
+ [codegen/README.md](./codegen/README.md) for the decision
178
+ rationale.
179
+
180
+ ## Source-of-truth + distribution
181
+
182
+ Source-of-truth: this directory in
183
+ [Tosh-Labs/blockchain0x-app](https://github.com/Tosh-Labs/blockchain0x-app)
184
+ under `packages/sdk-ruby/`.
185
+
186
+ Public mirror: [Tosh-Labs/blockchain0x-ruby](https://github.com/Tosh-Labs/blockchain0x-ruby)
187
+ (receives merges from this directory on dispatch of the
188
+ `mirror-sdk-ruby` workflow).
189
+
190
+ Distribution: [rubygems](https://rubygems.org/gems/blockchain0x) via
191
+ Trusted Publisher OIDC.
192
+
193
+ ## License
194
+
195
+ Apache-2.0.
@@ -0,0 +1,194 @@
1
+ # Sub-plan 21.3 row C-4: Ruby HTTP client.
2
+ #
3
+ # Behaviour mirrors the @blockchain0x/node + Python + Go SDKs:
4
+ #
5
+ # - injects Bearer + X-Network headers on every request,
6
+ # - mints an Idempotency-Key on POST/PATCH/DELETE unless caller
7
+ # supplies one,
8
+ # - retries 5xx + 429 with exponential backoff (Retry-After
9
+ # honoured),
10
+ # - converts the canonical error envelope into APIKeyError / Error.
11
+ #
12
+ # Network selection:
13
+ # - `sk_test_*` keys force testnet,
14
+ # - `sk_live_*` keys force mainnet,
15
+ # - explicit `network:` kwarg overrides the prefix when the SDK is
16
+ # used in mixed-mode tests.
17
+
18
+ # frozen_string_literal: true
19
+
20
+ require 'faraday'
21
+ require 'json'
22
+ require 'securerandom'
23
+ require_relative 'errors'
24
+ require_relative 'version'
25
+
26
+ module Blockchain0x
27
+ class Client
28
+ DEFAULT_BASE_URL = 'https://api.blockchain0x.com'
29
+ DEFAULT_TIMEOUT = 30
30
+ DEFAULT_MAX_RETRIES = 3
31
+ USER_AGENT = "blockchain0x-ruby/#{VERSION}"
32
+
33
+ # @param api_key [String] required; sk_test_... or sk_live_...
34
+ # @param base_url [String] override the default API host
35
+ # @param network [Symbol,String] :mainnet / :testnet override
36
+ # @param timeout [Integer] per-request wall-clock seconds
37
+ # @param max_retries [Integer] retry budget on 5xx + 429
38
+ # @param adapter [Symbol] Faraday adapter (default :net_http)
39
+ # @param connection [Faraday::Connection] override for tests
40
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, network: nil, timeout: DEFAULT_TIMEOUT,
41
+ max_retries: DEFAULT_MAX_RETRIES, adapter: :net_http, connection: nil)
42
+ raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.empty?
43
+
44
+ @api_key = api_key
45
+ @network = (network || network_from_key(api_key)).to_s
46
+ @max_retries = max_retries
47
+ @connection = connection || build_connection(base_url, timeout, adapter)
48
+ end
49
+
50
+ def api_keys
51
+ @api_keys ||= begin
52
+ require_relative 'resources/api_keys'
53
+ Resources::APIKeys.new(self)
54
+ end
55
+ end
56
+
57
+ def payment_requests
58
+ @payment_requests ||= begin
59
+ require_relative 'resources/payment_requests'
60
+ Resources::PaymentRequests.new(self)
61
+ end
62
+ end
63
+
64
+ def payments
65
+ @payments ||= begin
66
+ require_relative 'resources/payments'
67
+ Resources::Payments.new(self)
68
+ end
69
+ end
70
+
71
+ def transactions
72
+ @transactions ||= begin
73
+ require_relative 'resources/transactions'
74
+ Resources::Transactions.new(self)
75
+ end
76
+ end
77
+
78
+ # ---- low-level verb helpers --------------------------------------
79
+ def get(path, params: nil)
80
+ request(:get, path, params: params)
81
+ end
82
+
83
+ def post(path, body: nil, idempotency_key: nil)
84
+ request(:post, path, body: body, idempotency_key: idempotency_key)
85
+ end
86
+
87
+ def patch(path, body: nil, idempotency_key: nil)
88
+ request(:patch, path, body: body, idempotency_key: idempotency_key)
89
+ end
90
+
91
+ def delete(path, idempotency_key: nil)
92
+ request(:delete, path, idempotency_key: idempotency_key)
93
+ end
94
+
95
+ private
96
+
97
+ def request(method, path, params: nil, body: nil, idempotency_key: nil)
98
+ headers = {
99
+ 'Authorization' => "Bearer #{@api_key}",
100
+ 'Accept' => 'application/json',
101
+ 'User-Agent' => USER_AGENT,
102
+ 'X-Network' => @network,
103
+ }
104
+ headers['Content-Type'] = 'application/json' if body
105
+ if %i[post patch delete].include?(method)
106
+ headers['Idempotency-Key'] = idempotency_key || SecureRandom.uuid
107
+ end
108
+
109
+ payload = body.nil? ? nil : JSON.generate(body)
110
+
111
+ attempt = 0
112
+ loop do
113
+ attempt += 1
114
+ begin
115
+ response = @connection.run_request(method, path, payload, headers) do |req|
116
+ req.params.update(params) if params
117
+ end
118
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
119
+ raise Error.new(code: 'network.unavailable', message: e.message) if attempt > @max_retries
120
+
121
+ sleep(backoff(attempt))
122
+ next
123
+ end
124
+
125
+ if response.status < 400
126
+ return parse_success(response)
127
+ end
128
+
129
+ if response.status == 429 || response.status >= 500
130
+ if attempt > @max_retries
131
+ raise build_error(response)
132
+ end
133
+
134
+ wait = response.headers['Retry-After']&.to_i
135
+ sleep(wait || backoff(attempt))
136
+ next
137
+ end
138
+
139
+ raise build_error(response)
140
+ end
141
+ end
142
+
143
+ def parse_success(response)
144
+ return nil if response.status == 204 || response.body.nil? || response.body.empty?
145
+
146
+ JSON.parse(response.body)
147
+ rescue JSON::ParserError
148
+ raise Error.new(code: 'response.invalid_envelope', message: 'non-JSON response body')
149
+ end
150
+
151
+ def build_error(response)
152
+ request_id = response.headers['x-request-id']
153
+ payload = begin
154
+ JSON.parse(response.body || '')
155
+ rescue JSON::ParserError
156
+ nil
157
+ end
158
+ err = payload.is_a?(Hash) ? payload['error'] : nil
159
+ unless err.is_a?(Hash) && err['code']
160
+ return Error.new(
161
+ code: 'response.invalid_envelope',
162
+ message: "non-canonical error body (status=#{response.status})",
163
+ http_status: response.status,
164
+ request_id: request_id,
165
+ )
166
+ end
167
+
168
+ code = err['code'].to_s
169
+ message = err['message'].to_s
170
+ # The canonical error envelope carries the request id in the body
171
+ # (`error.requestId`); the `x-request-id` response header is a
172
+ # fallback for transports that surface it there instead.
173
+ request_id = err['requestId'] || request_id
174
+ klass = code.start_with?('apikey.') ? APIKeyError : Error
175
+ klass.new(code: code, message: message, http_status: response.status, request_id: request_id)
176
+ end
177
+
178
+ def backoff(attempt)
179
+ [0.5 * (2**(attempt - 1)), 30.0].min
180
+ end
181
+
182
+ def network_from_key(api_key)
183
+ api_key.start_with?('sk_live_') ? 'mainnet' : 'testnet'
184
+ end
185
+
186
+ def build_connection(base_url, timeout, adapter)
187
+ Faraday.new(url: base_url) do |f|
188
+ f.options.timeout = timeout
189
+ f.options.open_timeout = timeout
190
+ f.adapter adapter
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,85 @@
1
+ # Sub-plan 21.3 C-4 + C-6 (Ruby). Mirrors the @blockchain0x/node +
2
+ # Python + Go SDK error hierarchies so consumers branch on stable
3
+ # wire `code` strings instead of regex-matching messages.
4
+ #
5
+ # Blockchain0x::Error (every SDK error)
6
+ # Blockchain0x::APIKeyError (HTTP 401/403 with apikey.*)
7
+ # Blockchain0x::WebhookSignatureError (raised only by
8
+ # Webhooks.verify(..., raise_on_fail: true);
9
+ # default verify returns a Result struct)
10
+ #
11
+ # Every error carries:
12
+ # code stable wire-level code from the openapi catalog
13
+ # message human-readable description
14
+ # http_status the HTTP status that produced this error (nil for
15
+ # client-side raises like WebhookSignatureError)
16
+ # request_id server-issued request id (nil on local-only errors)
17
+
18
+ # frozen_string_literal: true
19
+
20
+ module Blockchain0x
21
+ class Error < StandardError
22
+ attr_reader :code, :http_status, :request_id
23
+
24
+ def initialize(code:, message:, http_status: nil, request_id: nil)
25
+ super("#{code}: #{message}")
26
+ @code = code
27
+ @http_status = http_status
28
+ @request_id = request_id
29
+ end
30
+ end
31
+
32
+ # Subclass for HTTP 401 / 403 envelopes whose `error.code` starts
33
+ # with `apikey.`. Catch this to handle apikey-related failures:
34
+ #
35
+ # begin
36
+ # client.api_keys.list
37
+ # rescue Blockchain0x::APIKeyError => e
38
+ # if e.code == 'apikey.scope_insufficient'
39
+ # # mint a fresh key with more scope
40
+ # end
41
+ # end
42
+ class APIKeyError < Error
43
+ end
44
+
45
+ # Raised by Webhooks.verify(..., raise_on_fail: true). The default
46
+ # `verify` path returns a discriminated-union Result; this exists
47
+ # for integrations that prefer exceptions over branching on
48
+ # `result.ok?`.
49
+ class WebhookSignatureError < Error
50
+ end
51
+
52
+ # @return [Boolean] true when the exception's code begins with
53
+ # `apikey.` (regardless of which subclass it is).
54
+ def self.apikey_error?(error)
55
+ return false unless error.respond_to?(:code) && error.code.is_a?(String)
56
+
57
+ error.code.start_with?('apikey.')
58
+ end
59
+
60
+ # Stable apikey.* failure-mode catalog (sub-plan 21.3 row C-8). The
61
+ # backend emits these codes verbatim in the wire envelope; SDK
62
+ # consumers branch on them via APIKeyError#code. Splits into four
63
+ # groups: identity-bound, agent-flavor binding, surface-restriction,
64
+ # and the four 21.3-introduced workspace-flavor codes.
65
+ module APIKeyCodes
66
+ # Identity-bound (the key itself is invalid).
67
+ INVALID = 'apikey.invalid'
68
+ REVOKED = 'apikey.revoked'
69
+ EXPIRED = 'apikey.expired'
70
+ # Agent-flavor binding errors (sub-plan 21.1).
71
+ AGENT_REVOKED = 'apikey.agent_revoked'
72
+ AGENT_MISMATCH = 'apikey.agent_mismatch'
73
+ # Surface-restriction errors.
74
+ WORKSPACE_ENDPOINT_BLOCKED = 'apikey.workspace_endpoint_blocked'
75
+ UNSUPPORTED_ENDPOINT = 'apikey.unsupported_endpoint'
76
+ # Network + scope.
77
+ NETWORK_MISMATCH = 'apikey.network_mismatch'
78
+ SCOPE_INSUFFICIENT = 'apikey.scope_insufficient'
79
+ # Workspace-flavor errors (sub-plan 21.3).
80
+ WALLET_NOT_ASSIGNED = 'apikey.wallet_not_assigned'
81
+ WORKSPACE_SCOPE_INSUFFICIENT = 'apikey.workspace_scope_insufficient'
82
+ ROLE_INSUFFICIENT_FOR_GRANTS = 'apikey.role_insufficient_for_grants'
83
+ NO_GRANTS_REMAINING = 'apikey.no_grants_remaining'
84
+ end
85
+ end
@@ -0,0 +1,129 @@
1
+ # Sub-plan 21.3 row C-4 first resource: api_keys.
2
+ #
3
+ # Thin wrapper over the wire shape. Until codegen lands
4
+ # (codegen/regenerate.sh per the C-1 decision), the response is a
5
+ # plain Hash mirroring the openapi `ApiKey` schema verbatim:
6
+ #
7
+ # { 'id' => ..., 'prefix' => ..., 'scopes' => [...],
8
+ # 'workspaceScopes' => [...], 'walletAssignments' => [...] }
9
+ #
10
+ # Once codegen ships, this resource returns typed model classes.
11
+ #
12
+ # C-4 follow-up: typed `create_workspace_key` + `create_agent_key`
13
+ # close the sdk-parity-matrix "Workspace-flavor `create` body" 🟡
14
+ # drift for Ruby. Mirrors the Python `create_workspace_key` +
15
+ # Go `CreateWorkspaceKey` signatures.
16
+
17
+ # frozen_string_literal: true
18
+
19
+ module Blockchain0x
20
+ module Resources
21
+ # WalletAssignment is the typed per-wallet entry for the
22
+ # workspace-flavor create body. Splits an `agent_id` + `scopes`
23
+ # array. The 4 valid scopes are `read_wallet_metadata`,
24
+ # `manage_wallet_metadata`, `pay_bills`, `receive_money`.
25
+ WalletAssignment = Struct.new(:agent_id, :scopes, keyword_init: true) do
26
+ def to_h_wire
27
+ { 'agentId' => agent_id, 'scopes' => Array(scopes) }
28
+ end
29
+ end
30
+
31
+ class APIKeys
32
+ def initialize(client)
33
+ @client = client
34
+ end
35
+
36
+ # @return [Hash] cursor-paginated page envelope
37
+ def list
38
+ @client.get('/v1/api-keys')
39
+ end
40
+
41
+ # @param api_key_id [String]
42
+ # @return [Hash]
43
+ def get(api_key_id)
44
+ raise ArgumentError, 'api_key_id is required' if api_key_id.nil? || api_key_id.empty?
45
+
46
+ @client.get("/v1/api-keys/#{api_key_id}")
47
+ end
48
+
49
+ # Revoke an API key. Returns nil (204 No Content).
50
+ def revoke(api_key_id)
51
+ raise ArgumentError, 'api_key_id is required' if api_key_id.nil? || api_key_id.empty?
52
+
53
+ @client.delete("/v1/api-keys/#{api_key_id}")
54
+ end
55
+
56
+ # Mint a sub-plan 21.3 workspace-flavor API key.
57
+ #
58
+ # At least one of `workspace_scopes` or `wallet_assignments`
59
+ # MUST be non-empty - a workspace-flavor key with neither
60
+ # rejects at the server with `request.invalid`. The client
61
+ # raises ArgumentError before issuing the wire call so the
62
+ # mistake is caught locally.
63
+ #
64
+ # @param label [String] human-readable label shown in the dashboard.
65
+ # @param workspace_scopes [Array<String>, nil] zero or more workspace-level scopes.
66
+ # @param wallet_assignments [Array<WalletAssignment>, nil] zero or more per-wallet grants.
67
+ # @param expires_in_days [Integer, nil] optional rotation hint (7/30/60/90).
68
+ # @param idempotency_key [String, nil] explicit override; otherwise the client mints a fresh uuid4.
69
+ # @return [Hash] the raw envelope `{ 'id', 'secret', 'prefix', ... }`. The `secret` field is the
70
+ # plaintext key returned ONCE; persist it now.
71
+ # @raise [APIKeyError] when the RBAC ceiling rejects the grant
72
+ # (`apikey.role_insufficient_for_grants`).
73
+ def create_workspace_key(
74
+ label:,
75
+ workspace_scopes: nil,
76
+ wallet_assignments: nil,
77
+ expires_in_days: nil,
78
+ idempotency_key: nil
79
+ )
80
+ raise ArgumentError, 'label is required' if label.nil? || label.empty?
81
+
82
+ ws = Array(workspace_scopes)
83
+ wa = Array(wallet_assignments)
84
+ if ws.empty? && wa.empty?
85
+ raise ArgumentError,
86
+ 'workspace-flavor key needs at least one workspace_scope OR wallet_assignment'
87
+ end
88
+
89
+ body = { 'label' => label }
90
+ body['workspaceScopes'] = ws unless ws.empty?
91
+ body['walletAssignments'] = wa.map { |a| a.respond_to?(:to_h_wire) ? a.to_h_wire : a } unless wa.empty?
92
+ body['expiresInDays'] = expires_in_days unless expires_in_days.nil?
93
+
94
+ @client.post('/v1/api-keys', body: body, idempotency_key: idempotency_key)
95
+ end
96
+
97
+ # Mint a legacy 21.1 agent-bound API key.
98
+ #
99
+ # @param agent_id [String] the agent the key is bound to.
100
+ # @param label [String] human-readable label.
101
+ # @param scopes [Array<String>] one or more wallet-scoped permissions.
102
+ # @param expires_in_days [Integer, nil] optional rotation hint.
103
+ # @param idempotency_key [String, nil] explicit override.
104
+ # @return [Hash] the raw envelope `{ 'id', 'secret', 'prefix', ... }`.
105
+ def create_agent_key(
106
+ agent_id:,
107
+ label:,
108
+ scopes:,
109
+ expires_in_days: nil,
110
+ idempotency_key: nil
111
+ )
112
+ raise ArgumentError, 'agent_id is required' if agent_id.nil? || agent_id.empty?
113
+ raise ArgumentError, 'label is required' if label.nil? || label.empty?
114
+
115
+ sc = Array(scopes)
116
+ raise ArgumentError, 'at least one scope is required' if sc.empty?
117
+
118
+ body = {
119
+ 'agentId' => agent_id,
120
+ 'label' => label,
121
+ 'scopes' => sc,
122
+ }
123
+ body['expiresInDays'] = expires_in_days unless expires_in_days.nil?
124
+
125
+ @client.post('/v1/api-keys', body: body, idempotency_key: idempotency_key)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,73 @@
1
+ # Sub-plan 21.2 row A-3 / B-3 (Ruby port).
2
+ #
3
+ # `client.payment_requests.settle` is the path the x402 server adapter
4
+ # calls after verifying an X-Payment header. The request body carries
5
+ # the on-chain proof tuple (txHash + payerAddress + amountUsdcVerified);
6
+ # the backend re-verifies it against the canonical `transactions`
7
+ # table before flipping the invoice to `settled`. Trust model: the
8
+ # SDK is a thin wrapper, the server is the trust anchor.
9
+ #
10
+ # No idempotency_key mint here - settle is naturally idempotent
11
+ # server-side: an invoice already in `settled` state returns 409
12
+ # `payment_request.not_settleable`, never a duplicate event.
13
+
14
+ # frozen_string_literal: true
15
+
16
+ module Blockchain0x
17
+ module Resources
18
+ # PaymentRequestSettleBody is the typed proof-tuple. Snake_case
19
+ # on the Ruby side, camelCase on the wire (the `#to_h_wire`
20
+ # serialiser handles the rename). Mirrors the Python
21
+ # PaymentRequestSettleBody dataclass + Go PaymentRequestSettleBody
22
+ # struct.
23
+ PaymentRequestSettleBody = Struct.new(
24
+ :tx_hash,
25
+ :payer_address,
26
+ :amount_usdc_verified,
27
+ keyword_init: true,
28
+ ) do
29
+ def to_h_wire
30
+ {
31
+ 'txHash' => tx_hash,
32
+ 'payerAddress' => payer_address,
33
+ 'amountUsdcVerified' => amount_usdc_verified,
34
+ }
35
+ end
36
+ end
37
+
38
+ # PaymentRequestsResource is `client.payment_requests.*`.
39
+ class PaymentRequests
40
+ def initialize(client)
41
+ @client = client
42
+ end
43
+
44
+ # Settle a payment request with the on-chain proof tuple.
45
+ #
46
+ # @param payment_request_id [String] the `pr_*` id from the 402
47
+ # `accepts[].paymentRequestId` field.
48
+ # @param body [PaymentRequestSettleBody, Hash] either the typed
49
+ # Struct OR a plain hash with the canonical camelCase shape -
50
+ # the x402 server adapter already passes the dict directly.
51
+ # @return [Hash] the raw envelope
52
+ # `{ 'id', 'status' => 'settled', 'settledTxHash', 'settledAt' }`.
53
+ # @raise [ArgumentError] when payment_request_id is empty.
54
+ # @raise [Blockchain0x::Error] on any non-2xx envelope.
55
+ def settle(payment_request_id, body)
56
+ if payment_request_id.nil? || payment_request_id.to_s.empty?
57
+ raise ArgumentError, 'payment_request_id is required'
58
+ end
59
+
60
+ wire_body =
61
+ if body.respond_to?(:to_h_wire)
62
+ body.to_h_wire
63
+ else
64
+ # Accept the canonical camelCase shape verbatim - the
65
+ # x402 server adapter already passes this exact shape.
66
+ body.to_h.transform_keys(&:to_s)
67
+ end
68
+
69
+ @client.post("/v1/payment-requests/#{payment_request_id}/settle", body: wire_body)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,82 @@
1
+ # Sub-plan 21.1 row B-4 (Ruby port).
2
+ #
3
+ # `client.payments.create` is the agent's outbound-spend path. Two
4
+ # safety differences from every other SDK call:
5
+ #
6
+ # 1. **Auto Idempotency-Key.** The underlying `Client#post` already
7
+ # mints a uuid4 on every POST; this resource exposes an explicit
8
+ # `idempotency_key:` knob so consumers can thread a stable value
9
+ # across retries.
10
+ # 2. **Retry stays at the global Client setting.** A payment retry
11
+ # without a matching idempotency key could double-submit. The
12
+ # Ruby SDK does not yet expose a per-call retry override (the
13
+ # Node SDK's `retry: 'off' | 'default'` switch is a future
14
+ # extension); consumers control retry via the Client's
15
+ # `max_retries:` argument.
16
+
17
+ # frozen_string_literal: true
18
+
19
+ module Blockchain0x
20
+ module Resources
21
+ # PaymentCreateBody is the typed body for POST /v1/payments.
22
+ # Snake_case on the Ruby side; camelCase on the wire (via
23
+ # `#to_h_wire`). Mirrors the Python PaymentCreateBody dataclass +
24
+ # Go PaymentCreateRequest struct.
25
+ PaymentCreateBody = Struct.new(
26
+ :agent_id,
27
+ :to,
28
+ :amount_wei,
29
+ :token,
30
+ :metadata,
31
+ keyword_init: true,
32
+ ) do
33
+ def initialize(agent_id:, to:, amount_wei:, token: nil, metadata: nil)
34
+ super
35
+ end
36
+
37
+ def to_h_wire
38
+ h = {
39
+ 'agentId' => agent_id,
40
+ 'to' => to,
41
+ 'amountWei' => amount_wei,
42
+ }
43
+ h['token'] = token unless token.nil?
44
+ h['metadata'] = metadata.to_h unless metadata.nil?
45
+ h
46
+ end
47
+ end
48
+
49
+ # `client.payments.*` resource.
50
+ class Payments
51
+ def initialize(client)
52
+ @client = client
53
+ end
54
+
55
+ # Submit an outbound payment.
56
+ #
57
+ # @param body [PaymentCreateBody, Hash] either the typed Struct
58
+ # OR a plain hash with the canonical camelCase shape - the
59
+ # x402 client wrapper passes a hash directly so it just works.
60
+ # @param idempotency_key [String, nil] explicit override;
61
+ # otherwise the underlying Client mints a uuid4.
62
+ # @return [Hash] raw envelope
63
+ # `{ 'id', 'agentId', 'status', 'txHash'?, 'amountWei', 'network', ... }`.
64
+ # @raise [ArgumentError] when any required field is empty.
65
+ # @raise [Blockchain0x::Error] on any non-2xx envelope.
66
+ def create(body:, idempotency_key: nil)
67
+ wire =
68
+ if body.respond_to?(:to_h_wire)
69
+ body.to_h_wire
70
+ else
71
+ body.to_h.transform_keys(&:to_s)
72
+ end
73
+
74
+ raise ArgumentError, 'agentId is required' if wire['agentId'].nil? || wire['agentId'].empty?
75
+ raise ArgumentError, 'to is required' if wire['to'].nil? || wire['to'].empty?
76
+ raise ArgumentError, 'amountWei is required' if wire['amountWei'].nil? || wire['amountWei'].empty?
77
+
78
+ @client.post('/v1/payments', body: wire, idempotency_key: idempotency_key)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,32 @@
1
+ # Sub-plan 21.2 row B-3 (Ruby port).
2
+ #
3
+ # Read-only handle on the `transactions` table. The x402 client polls
4
+ # `client.transactions.get` to find out when a freshly broadcast
5
+ # `payments.create` has confirmed on-chain (status flips to
6
+ # `confirmed`). Scope: `read_wallet_metadata`.
7
+
8
+ # frozen_string_literal: true
9
+
10
+ module Blockchain0x
11
+ module Resources
12
+ class Transactions
13
+ def initialize(client)
14
+ @client = client
15
+ end
16
+
17
+ # Fetch one transaction by id.
18
+ #
19
+ # @param transaction_id [String] the `tx_*` id returned from
20
+ # `payments.create` (or a chain webhook).
21
+ # @return [Hash] raw envelope.
22
+ # @raise [ArgumentError] when transaction_id is empty.
23
+ def get(transaction_id)
24
+ if transaction_id.nil? || transaction_id.to_s.empty?
25
+ raise ArgumentError, 'transaction_id is required'
26
+ end
27
+
28
+ @client.get("/v1/transactions/#{transaction_id}")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ # Sub-plan 21.3 C-4: gem version constant. Single source of truth for
2
+ # blockchain0x.gemspec and the user-visible Blockchain0x::VERSION.
3
+
4
+ # frozen_string_literal: true
5
+
6
+ module Blockchain0x
7
+ VERSION = '0.0.1.alpha.0'
8
+ end
@@ -0,0 +1,206 @@
1
+ # Sub-plan 21.3 row C-6 (Ruby). Ports the @blockchain0x/node + Python
2
+ # + Go `webhooks.verify` byte-for-byte.
3
+ #
4
+ # Wire format (mirrors apps/worker/src/adapters/webhook.ts):
5
+ #
6
+ # X-Blockchain0x-Timestamp: <unix seconds>
7
+ # X-Blockchain0x-Signature: t=<ts>,v1=<hex>
8
+ # X-Blockchain0x-Event-Type: <slug>
9
+ # X-Blockchain0x-Event-Id: <ulid>
10
+ # X-Blockchain0x-Delivery-Id: webhook_<id>
11
+ #
12
+ # Algorithm:
13
+ #
14
+ # want = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{raw_body}")
15
+ # ok = OpenSSL.fixed_length_secure_compare(want, sig) &&
16
+ # (Time.now.to_i - ts).abs <= tolerance_seconds
17
+ #
18
+ # Discriminated-union return so callers branch on `result.ok?`:
19
+ #
20
+ # result = Blockchain0x::Webhooks.verify(
21
+ # headers: request.headers, raw_body: request.raw_post,
22
+ # secret: ENV['BLOCKCHAIN0X_WEBHOOK_SECRET'],
23
+ # )
24
+ # return head(:bad_request) unless result.ok?
25
+ #
26
+ # The verifier accepts EITHER the structured `t=<ts>,v1=<hex>` value
27
+ # OR a bare hex signature (some load balancers strip comma-delimited
28
+ # values); when bare-hex, the `t` value is read from the
29
+ # X-Blockchain0x-Timestamp header.
30
+
31
+ # frozen_string_literal: true
32
+
33
+ require 'openssl'
34
+ require_relative 'errors'
35
+
36
+ module Blockchain0x
37
+ module Webhooks
38
+ SIG_HEADER = 'X-Blockchain0x-Signature'
39
+ TS_HEADER = 'X-Blockchain0x-Timestamp'
40
+ TYPE_HEADER = 'X-Blockchain0x-Event-Type'
41
+ EVENT_ID_HEADER = 'X-Blockchain0x-Event-Id'
42
+ DELIVERY_ID_HEADER = 'X-Blockchain0x-Delivery-Id'
43
+
44
+ DEFAULT_TOLERANCE_SECONDS = 300
45
+
46
+ # Failure-code constants mirror @blockchain0x/node + Python + Go.
47
+ SIGNATURE_MISSING = 'webhook.signature_missing'
48
+ SIGNATURE_MALFORMED = 'webhook.signature_malformed'
49
+ TIMESTAMP_OUTSIDE_WINDOW = 'webhook.timestamp_outside_window'
50
+ SIGNATURE_MISMATCH = 'webhook.signature_mismatch'
51
+ SECRET_MISSING = 'webhook.secret_missing'
52
+ TIMESTAMP_MISSING = 'webhook.timestamp_missing'
53
+ TIMESTAMP_INVALID = 'webhook.timestamp_invalid'
54
+
55
+ # Result returned by `verify`. Use `result.ok?` to branch.
56
+ #
57
+ # On success, `event_type`, `event_id`, `delivery_id` are
58
+ # populated (may be nil if the framework dropped the header but
59
+ # the signature verified).
60
+ #
61
+ # On failure, `code` is one of the constants above and the
62
+ # event-detail fields are nil.
63
+ Result = Struct.new(:ok, :code, :event_type, :event_id, :delivery_id, keyword_init: true) do
64
+ def ok?
65
+ ok == true
66
+ end
67
+ end
68
+
69
+ class << self
70
+ # @param headers [Hash] case-insensitive header bag (a plain
71
+ # Hash works, ActionController headers work)
72
+ # @param raw_body [String] request body EXACTLY as it arrived;
73
+ # do not pre-parse JSON
74
+ # @param secret [String] webhook signing secret
75
+ # @param tolerance_seconds [Integer] override the 5-minute default
76
+ # @param now [Integer] override `Time.now.to_i` for tests
77
+ # @param raise_on_fail [Boolean] when true, raise
78
+ # WebhookSignatureError on any failure
79
+ # @return [Result] discriminated-union result (default path)
80
+ # @raise [WebhookSignatureError] when raise_on_fail: true and verify fails
81
+ def verify(headers:, raw_body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil, raise_on_fail: false)
82
+ if secret.nil? || secret.empty?
83
+ return fail_result(SECRET_MISSING, 'missing webhook secret', raise_on_fail)
84
+ end
85
+
86
+ raw_sig = pick_header(headers, SIG_HEADER)
87
+ if raw_sig.nil?
88
+ return fail_result(SIGNATURE_MISSING, 'missing X-Blockchain0x-Signature header', raise_on_fail)
89
+ end
90
+
91
+ parsed = parse_signature(raw_sig)
92
+ return fail_result(SIGNATURE_MALFORMED, 'malformed signature header', raise_on_fail) if parsed.nil?
93
+
94
+ ts_from_header, sig_hex = parsed
95
+ ts = ts_from_header
96
+ if ts.nil?
97
+ raw_ts = pick_header(headers, TS_HEADER)
98
+ return fail_result(TIMESTAMP_MISSING, 'missing X-Blockchain0x-Timestamp header', raise_on_fail) if raw_ts.nil?
99
+
100
+ begin
101
+ ts = Integer(raw_ts)
102
+ rescue ArgumentError, TypeError
103
+ return fail_result(TIMESTAMP_INVALID, 'invalid X-Blockchain0x-Timestamp value', raise_on_fail)
104
+ end
105
+ end
106
+
107
+ current = now || Time.now.to_i
108
+ if (current - ts).abs > tolerance_seconds
109
+ return fail_result(TIMESTAMP_OUTSIDE_WINDOW, 'timestamp outside tolerance window', raise_on_fail)
110
+ end
111
+
112
+ want = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{raw_body}")
113
+ unless secure_compare(want, sig_hex.downcase)
114
+ return fail_result(SIGNATURE_MISMATCH, 'signature mismatch', raise_on_fail)
115
+ end
116
+
117
+ Result.new(
118
+ ok: true,
119
+ code: nil,
120
+ event_type: pick_header(headers, TYPE_HEADER),
121
+ event_id: pick_header(headers, EVENT_ID_HEADER),
122
+ delivery_id: pick_header(headers, DELIVERY_ID_HEADER),
123
+ )
124
+ end
125
+
126
+ private
127
+
128
+ def pick_header(headers, name)
129
+ return nil if headers.nil?
130
+
131
+ # Try the canonical name first (works for ActionDispatch::Headers
132
+ # and most middleware bags), then fall back to a case-insensitive
133
+ # lookup on plain Hash inputs.
134
+ if headers.respond_to?(:[])
135
+ v = headers[name]
136
+ return v if !v.nil? && !v.to_s.empty?
137
+
138
+ v = headers[name.downcase]
139
+ return v if !v.nil? && !v.to_s.empty?
140
+ end
141
+ return nil unless headers.respond_to?(:each)
142
+
143
+ target = name.downcase
144
+ headers.each do |k, v|
145
+ return v if k.to_s.downcase == target && !v.to_s.empty?
146
+ end
147
+ nil
148
+ end
149
+
150
+ # Parses `t=<ts>,v1=<hex>` OR a bare hex string.
151
+ # Returns [ts_or_nil, sig_hex] on success; nil on malformed input.
152
+ def parse_signature(raw)
153
+ unless raw.include?('=')
154
+ cleaned = raw.strip
155
+ return nil unless cleaned =~ /\A[0-9a-fA-F]+\z/
156
+
157
+ return [nil, cleaned.downcase]
158
+ end
159
+ ts = nil
160
+ sig = nil
161
+ raw.split(',').each do |part|
162
+ key, _eq, value = part.strip.partition('=')
163
+ case key
164
+ when 't'
165
+ begin
166
+ ts = Integer(value)
167
+ rescue ArgumentError, TypeError
168
+ return nil
169
+ end
170
+ when 'v1'
171
+ sig = value.strip.downcase
172
+ end
173
+ end
174
+ return nil if sig.nil?
175
+
176
+ [ts, sig]
177
+ end
178
+
179
+ # Constant-time compare. Ruby 3.0+ with a modern openssl gem
180
+ # exposes OpenSSL.fixed_length_secure_compare; older stdlib
181
+ # builds (e.g. some minimal docker images) ship without it, so
182
+ # fall back to a manual byte XOR-and-OR that scans the full
183
+ # input regardless of where the mismatch is. Either path
184
+ # short-circuits on a length mismatch BEFORE the constant-time
185
+ # compare; signatures are sized by the algorithm, not by the
186
+ # caller, so the length is not user-secret.
187
+ def secure_compare(a, b)
188
+ return false unless a.bytesize == b.bytesize
189
+
190
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
191
+ OpenSSL.fixed_length_secure_compare(a, b)
192
+ else
193
+ diff = 0
194
+ a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
195
+ diff.zero?
196
+ end
197
+ end
198
+
199
+ def fail_result(code, message, raise_on_fail)
200
+ raise WebhookSignatureError.new(code: code, message: message) if raise_on_fail
201
+
202
+ Result.new(ok: false, code: code)
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,33 @@
1
+ # Sub-plan 21.3 row C-4: top-level module + autoload setup.
2
+ #
3
+ # Public surface:
4
+ # Blockchain0x::Client HTTP client (client.api_keys.*)
5
+ # Blockchain0x::Webhooks webhook signature verifier
6
+ # Blockchain0x::Error base exception class
7
+ # Blockchain0x::APIKeyError 401/403 apikey.* envelope subclass
8
+ # Blockchain0x::APIKeyCodes 13 stable apikey.* failure codes (sub-plan 21.3 C-8)
9
+ # Blockchain0x::WebhookSignatureError raised by Webhooks.verify(raise_on_fail: true)
10
+ # Blockchain0x::Resources::WalletAssignment typed per-wallet entry for create_workspace_key
11
+ # Blockchain0x::Resources::PaymentRequestSettleBody typed proof tuple for payment_requests.settle
12
+ # Blockchain0x::Resources::PaymentCreateBody typed body for payments.create
13
+ # Blockchain0x::VERSION gem version string
14
+
15
+ # frozen_string_literal: true
16
+
17
+ require_relative 'blockchain0x/version'
18
+ require_relative 'blockchain0x/errors'
19
+ require_relative 'blockchain0x/webhooks'
20
+ # Client is required lazily so consumers who only use Webhooks do not
21
+ # pay the Faraday load cost.
22
+
23
+ module Blockchain0x
24
+ # @return [Class] Blockchain0x::Client (lazy-loaded)
25
+ def self.const_missing(name)
26
+ if name == :Client
27
+ require_relative 'blockchain0x/client'
28
+ const_get(:Client)
29
+ else
30
+ super
31
+ end
32
+ end
33
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: blockchain0x
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha.0
5
+ platform: ruby
6
+ authors:
7
+ - Blockchain0x
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rspec
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.13'
47
+ - !ruby/object:Gem::Dependency
48
+ name: webmock
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.23'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.23'
61
+ description: Non-custodial AI-agent wallet platform on Base - authenticate, read balances
62
+ + transactions, send + receive payments, and verify webhook signatures.
63
+ email:
64
+ - support@blockchain0x.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - LICENSE
70
+ - README.md
71
+ - lib/blockchain0x.rb
72
+ - lib/blockchain0x/client.rb
73
+ - lib/blockchain0x/errors.rb
74
+ - lib/blockchain0x/resources/api_keys.rb
75
+ - lib/blockchain0x/resources/payment_requests.rb
76
+ - lib/blockchain0x/resources/payments.rb
77
+ - lib/blockchain0x/resources/transactions.rb
78
+ - lib/blockchain0x/version.rb
79
+ - lib/blockchain0x/webhooks.rb
80
+ homepage: https://blockchain0x.com
81
+ licenses:
82
+ - Apache-2.0
83
+ metadata:
84
+ homepage_uri: https://blockchain0x.com
85
+ source_code_uri: https://github.com/Tosh-Labs/blockchain0x-app/tree/dev/packages/sdk-ruby
86
+ bug_tracker_uri: https://github.com/Tosh-Labs/blockchain0x-app/issues
87
+ documentation_uri: https://docs.blockchain0x.com
88
+ changelog_uri: https://github.com/Tosh-Labs/blockchain0x-ruby/blob/main/CHANGELOG.md
89
+ rubygems_mfa_required: 'true'
90
+ post_install_message:
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '3.0'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.5.22
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: Official Ruby SDK for Blockchain0x
109
+ test_files: []