remitmd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 137668b963c8ed6bef29b8990746e9eea1c0474f788d4365918ffd52306907b8
4
+ data.tar.gz: eb3673564e6988838fd505c47244023dad202039f87346d837956a9be375d7aa
5
+ SHA512:
6
+ metadata.gz: 32d99f7c5d422b4c1fc7b56632177f8cdb414797d62572ab38c398c38b7b4a9df935056b65a1768db63f2aa96817a5363b3bc83d949011538a7c31323e7bff23
7
+ data.tar.gz: 78209500b2789302ca55566b7388551dab2521f26305e77aedea628c1234e1b780fbbf19c9468934dbcbe24dd34bbc08aaa5811f3fb1e5772fa704b32c19e611
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 remit-md
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # remit.md Ruby SDK
2
+
3
+ Universal payment protocol for AI agents — Ruby client library.
4
+
5
+ [![CI](https://github.com/remit-md/sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/remit-md/sdk/actions/workflows/ci.yml)
6
+ [![Gem Version](https://badge.fury.io/rb/remitmd.svg)](https://badge.fury.io/rb/remitmd)
7
+
8
+ ## Installation
9
+
10
+ ```ruby
11
+ gem "remitmd"
12
+ ```
13
+
14
+ or install directly:
15
+
16
+ ```bash
17
+ gem install remitmd
18
+ ```
19
+
20
+ ## Quickstart
21
+
22
+ ```ruby
23
+ require "remitmd"
24
+
25
+ wallet = Remitmd::RemitWallet.new(private_key: ENV["REMITMD_PRIVATE_KEY"])
26
+
27
+ # Direct payment
28
+ tx = wallet.pay("0xRecipient0000000000000000000000000000001", 1.50)
29
+ puts tx.tx_hash
30
+
31
+ # Check reputation
32
+ rep = wallet.reputation("0xSomeAgent000000000000000000000000001")
33
+ puts "Score: #{rep.score}"
34
+ ```
35
+
36
+ Or from environment variables:
37
+
38
+ ```ruby
39
+ wallet = Remitmd::RemitWallet.from_env
40
+ # Requires: REMITMD_PRIVATE_KEY
41
+ # Optional: REMITMD_CHAIN (default: "base"), REMITMD_API_URL
42
+ ```
43
+
44
+ ## Permits (Gasless USDC Approval)
45
+
46
+ ```ruby
47
+ contracts = wallet.get_contracts
48
+
49
+ # Use permits on any payment method
50
+ tx = wallet.pay("0xRecipient...", 5.00, permit: permit)
51
+ ```
52
+
53
+ Permits are optional on: `pay`, `create_escrow`, `create_tab`, `create_stream`, `create_bounty`, `place_deposit`.
54
+
55
+ ## Payment Models
56
+
57
+ ### Direct Payment
58
+
59
+ ```ruby
60
+ tx = wallet.pay("0xRecipient...", 5.00, memo: "AI inference fee", permit: permit)
61
+ ```
62
+
63
+ ### Escrow
64
+
65
+ ```ruby
66
+ escrow = wallet.create_escrow("0xContractor...", 100.00, memo: "Code review")
67
+ # Work happens...
68
+ tx = wallet.release_escrow(escrow.id) # pay the contractor
69
+ # or
70
+ tx = wallet.cancel_escrow(escrow.id) # refund yourself
71
+ ```
72
+
73
+ ### Metered Tab (off-chain billing)
74
+
75
+ ```ruby
76
+ tab = wallet.create_tab("0xProvider...", 50.00, 0.003, permit: permit)
77
+
78
+ # Provider charges with EIP-712 signature
79
+ sig = wallet.sign_tab_charge(contracts["tab"], tab.id, 3_000_000, 1)
80
+ wallet.charge_tab(tab.id, 0.003, 0.003, 1, sig)
81
+
82
+ # Close when done — unused funds return
83
+ wallet.close_tab(tab.id)
84
+ ```
85
+
86
+ ### Payment Stream
87
+
88
+ ```ruby
89
+ stream = wallet.create_stream("0xWorker...", 0.001, 100.00)
90
+ # Worker receives 0.001 USDC/second, funded with 100 USDC deposit
91
+
92
+ tx = wallet.withdraw_stream(stream.id)
93
+ ```
94
+
95
+ ### Bounty
96
+
97
+ ```ruby
98
+ bounty = wallet.create_bounty(25.00, "Summarise top 10 EIPs of 2025")
99
+
100
+ # Any agent can submit work; you decide the winner
101
+ tx = wallet.award_bounty(bounty.id, "0xWinner...")
102
+ ```
103
+
104
+ ### Security Deposit
105
+
106
+ ```ruby
107
+ dep = wallet.place_deposit("0xCounterpart...", 100.00, expires_in_secs: 86_400, permit: permit)
108
+ wallet.return_deposit(dep.id)
109
+ ```
110
+
111
+ ### Payment Intent
112
+
113
+ ```ruby
114
+ # Propose payment terms before committing
115
+ intent = wallet.propose_intent("0xCounterpart...", 50.00, type: "escrow")
116
+ ```
117
+
118
+ ## Testing with MockRemit
119
+
120
+ MockRemit gives you a zero-network, zero-latency test double. No API key needed.
121
+
122
+ ```ruby
123
+ require "remitmd"
124
+
125
+ RSpec.describe MyPayingAgent do
126
+ let(:mock) { Remitmd::MockRemit.new }
127
+ let(:wallet) { mock.wallet }
128
+
129
+ after { mock.reset }
130
+
131
+ it "pays the correct amount" do
132
+ agent = MyPayingAgent.new(wallet: wallet)
133
+ agent.run(task: "summarise document")
134
+
135
+ expect(mock.was_paid?("0xProvider...", 0.003)).to be true
136
+ expect(mock.balance).to eq(BigDecimal("9999.997"))
137
+ end
138
+ end
139
+ ```
140
+
141
+ ### MockRemit assertions
142
+
143
+ ```ruby
144
+ mock.was_paid?(address, amount) # true/false
145
+ mock.total_paid_to(address) # BigDecimal — sum of all payments to address
146
+ mock.transaction_count # Integer
147
+ mock.balance # BigDecimal — current balance
148
+ mock.transactions # Array<Transaction>
149
+ mock.set_balance(amount) # Override starting balance
150
+ mock.reset # Clear all state
151
+ ```
152
+
153
+ ## All Methods
154
+
155
+ ```ruby
156
+ # Contract discovery (cached per session)
157
+ wallet.get_contracts # Hash
158
+
159
+ # Balance & analytics
160
+ wallet.balance # Balance
161
+ wallet.history(limit: 50, offset: 0) # TransactionList
162
+ wallet.reputation(address) # Reputation
163
+ wallet.spending_summary # SpendingSummary
164
+ wallet.remaining_budget # Budget
165
+
166
+ # Direct payment
167
+ wallet.pay(to, amount, memo: nil, permit: nil) # Transaction
168
+
169
+ # Escrow
170
+ wallet.create_escrow(payee, amount, memo: nil, expires_in_secs: nil, permit: nil) # Escrow
171
+ wallet.claim_start(escrow_id) # Escrow
172
+ wallet.release_escrow(escrow_id, memo: nil) # Transaction
173
+ wallet.cancel_escrow(escrow_id) # Transaction
174
+ wallet.get_escrow(escrow_id) # Escrow
175
+
176
+ # Tabs
177
+ wallet.create_tab(provider, limit, per_unit, expires_in_secs: 86_400, permit: nil) # Tab
178
+ wallet.charge_tab(tab_id, amount, cumulative, call_count, provider_sig) # TabCharge
179
+ wallet.close_tab(tab_id, final_amount: nil, provider_sig: nil) # Tab
180
+
181
+ # Tab provider (signing charges)
182
+ wallet.sign_tab_charge(tab_contract, tab_id, total_charged, call_count) # String
183
+
184
+ # Streams
185
+ wallet.create_stream(payee, rate_per_second, max_total, permit: nil) # Stream
186
+ wallet.close_stream(stream_id) # Stream
187
+ wallet.withdraw_stream(stream_id) # Transaction
188
+
189
+ # Bounties
190
+ wallet.create_bounty(amount, task, deadline, max_attempts: 10, permit: nil) # Bounty
191
+ wallet.submit_bounty(bounty_id, evidence_hash) # BountySubmission
192
+ wallet.award_bounty(bounty_id, submission_id) # Bounty
193
+
194
+ # Deposits
195
+ wallet.place_deposit(provider, amount, expires_in_secs: 3600, permit: nil) # Deposit
196
+ wallet.return_deposit(deposit_id) # Transaction
197
+
198
+ # Webhooks
199
+ wallet.register_webhook(url, events, chains: nil) # Webhook
200
+
201
+ # Operator links
202
+ wallet.create_fund_link # LinkResponse
203
+ wallet.create_withdraw_link # LinkResponse
204
+
205
+ # Testnet
206
+ wallet.mint(amount) # Hash {tx_hash, balance}
207
+ ```
208
+
209
+ ## Error Handling
210
+
211
+ All errors are `Remitmd::RemitError` with structured fields and enriched details:
212
+
213
+ ```ruby
214
+ begin
215
+ wallet.pay("0xRecipient...", 100.00)
216
+ rescue Remitmd::RemitError => e
217
+ puts e.code # "INSUFFICIENT_BALANCE"
218
+ puts e.message # "Insufficient USDC balance: have $5.00, need $100.00"
219
+ puts e.doc_url # Direct link to error documentation
220
+ puts e.context # Hash: {"required" => "100.00", "available" => "5.00", ...}
221
+ end
222
+ ```
223
+
224
+ ## Custom Signer
225
+
226
+ Implement `Remitmd::Signer` for HSM, KMS, or multi-sig workflows:
227
+
228
+ ```ruby
229
+ class MyHsmSigner
230
+ include Remitmd::Signer
231
+
232
+ def sign(message)
233
+ # Delegate to your HSM
234
+ MyHsm.sign(message)
235
+ end
236
+
237
+ def address
238
+ "0xYourAddress..."
239
+ end
240
+ end
241
+
242
+ wallet = Remitmd::RemitWallet.new(signer: MyHsmSigner.new)
243
+ ```
244
+
245
+ ## Chains
246
+
247
+ ```ruby
248
+ Remitmd::RemitWallet.new(private_key: key, chain: "base") # Base mainnet (default)
249
+ Remitmd::RemitWallet.new(private_key: key, chain: "base_sepolia") # Base Sepolia testnet
250
+ ```
251
+
252
+ ## License
253
+
254
+ MIT — see [LICENSE](LICENSE)
255
+
256
+ [Documentation](https://remit.md/docs) · [Protocol Spec](https://remit.md) · [GitHub](https://github.com/remit-md/sdk)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module RemitMd
8
+ # A2A capability extension declared in an agent card.
9
+ AgentExtension = Data.define(:uri, :description, :required)
10
+
11
+ # A single skill declared in an A2A agent card.
12
+ AgentSkill = Data.define(:id, :name, :description, :tags)
13
+
14
+ # A2A agent card parsed from +/.well-known/agent-card.json+.
15
+ class AgentCard
16
+ attr_reader :protocol_version, :name, :description, :url, :version,
17
+ :documentation_url, :capabilities, :skills, :x402
18
+
19
+ def initialize(data)
20
+ @protocol_version = data["protocolVersion"] || "0.6"
21
+ @name = data["name"].to_s
22
+ @description = data["description"].to_s
23
+ @url = data["url"].to_s
24
+ @version = data["version"].to_s
25
+ @documentation_url = data["documentationUrl"].to_s
26
+ @capabilities = data["capabilities"] || {}
27
+ @skills = (data["skills"] || []).map do |s|
28
+ AgentSkill.new(
29
+ id: s["id"].to_s,
30
+ name: s["name"].to_s,
31
+ description: s["description"].to_s,
32
+ tags: s["tags"] || []
33
+ )
34
+ end
35
+ @x402 = data["x402"] || {}
36
+ end
37
+
38
+ # Fetch and parse the A2A agent card from +base_url/.well-known/agent-card.json+.
39
+ #
40
+ # card = RemitMd::AgentCard.discover("https://remit.md")
41
+ # puts card.name # => "remit.md"
42
+ # puts card.url # => "https://remit.md/a2a"
43
+ #
44
+ # @param base_url [String] root URL of the agent
45
+ # @return [AgentCard]
46
+ def self.discover(base_url)
47
+ url = URI("#{base_url.chomp("/")}/.well-known/agent-card.json")
48
+ response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |http|
49
+ req = Net::HTTP::Get.new(url)
50
+ req["Accept"] = "application/json"
51
+ http.request(req)
52
+ end
53
+ raise "Agent card discovery failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
54
+
55
+ new(JSON.parse(response.body))
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remitmd
4
+ # Structured error raised by all remit.md SDK operations.
5
+ #
6
+ # Every error has a machine-readable code, a human-readable message with
7
+ # actionable context, and a doc_url pointing to the specific error documentation.
8
+ #
9
+ # @example
10
+ # begin
11
+ # wallet.pay("not-an-address", 1.00)
12
+ # rescue Remitmd::RemitError => e
13
+ # puts e.code # => "INVALID_ADDRESS"
14
+ # puts e.message # => "[INVALID_ADDRESS] expected 0x-prefixed ..."
15
+ # puts e.doc_url # => "https://remit.md/docs/api-reference/error-codes#invalid_address"
16
+ # end
17
+ class RemitError < StandardError
18
+ # Error code constants
19
+ INVALID_ADDRESS = "INVALID_ADDRESS"
20
+ INVALID_AMOUNT = "INVALID_AMOUNT"
21
+ INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS"
22
+ ESCROW_NOT_FOUND = "ESCROW_NOT_FOUND"
23
+ TAB_NOT_FOUND = "TAB_NOT_FOUND"
24
+ STREAM_NOT_FOUND = "STREAM_NOT_FOUND"
25
+ BOUNTY_NOT_FOUND = "BOUNTY_NOT_FOUND"
26
+ DEPOSIT_NOT_FOUND = "DEPOSIT_NOT_FOUND"
27
+ UNAUTHORIZED = "UNAUTHORIZED"
28
+ RATE_LIMITED = "RATE_LIMITED"
29
+ NETWORK_ERROR = "NETWORK_ERROR"
30
+ SERVER_ERROR = "SERVER_ERROR"
31
+ NONCE_REUSED = "NONCE_REUSED"
32
+ SIGNATURE_INVALID = "SIGNATURE_INVALID"
33
+ ESCROW_ALREADY_RELEASED = "ESCROW_ALREADY_RELEASED"
34
+ ESCROW_EXPIRED = "ESCROW_EXPIRED"
35
+ TAB_LIMIT_EXCEEDED = "TAB_LIMIT_EXCEEDED"
36
+ BOUNTY_ALREADY_AWARDED = "BOUNTY_ALREADY_AWARDED"
37
+ STREAM_NOT_ACTIVE = "STREAM_NOT_ACTIVE"
38
+ DEPOSIT_ALREADY_SETTLED = "DEPOSIT_ALREADY_SETTLED"
39
+ USDC_TRANSFER_FAILED = "USDC_TRANSFER_FAILED"
40
+ CHAIN_UNAVAILABLE = "CHAIN_UNAVAILABLE"
41
+
42
+ attr_reader :code, :doc_url, :context
43
+
44
+ def initialize(code, message, context: {})
45
+ @code = code
46
+ @doc_url = "https://remit.md/docs/api-reference/error-codes##{code.downcase}"
47
+ @context = context
48
+ super("[#{code}] #{message} — #{@doc_url}")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "securerandom"
7
+ require "openssl"
8
+
9
+ module Remitmd
10
+ # Chain configuration: maps chain names to (api_url, chain_id) pairs.
11
+ CHAIN_CONFIG = {
12
+ "base" => { url: "https://api.remit.md/api/v0", chain_id: 8453 },
13
+ "base_sepolia" => { url: "https://testnet.remit.md/api/v0", chain_id: 84532 },
14
+ }.freeze
15
+
16
+ # HTTP transport layer. Signs each request with EIP-712 auth headers and
17
+ # retries transient failures with exponential backoff.
18
+ class HttpTransport
19
+ MAX_RETRIES = 3
20
+ BASE_DELAY = 0.5 # seconds
21
+ RETRY_CODES = [429, 500, 502, 503, 504].freeze
22
+
23
+ def initialize(base_url:, signer:, chain_id:, router_address: "")
24
+ @signer = signer
25
+ @chain_id = chain_id
26
+ @router_address = router_address.to_s
27
+ @uri = URI.parse(base_url)
28
+ @http = build_http(@uri)
29
+ end
30
+
31
+ def get(path)
32
+ request(:get, path, nil)
33
+ end
34
+
35
+ def post(path, body = nil)
36
+ request(:post, path, body)
37
+ end
38
+
39
+ private
40
+
41
+ def request(method, path, body)
42
+ attempt = 0
43
+ begin
44
+ attempt += 1
45
+ req = build_request(method, path, body)
46
+ resp = @http.request(req)
47
+ handle_response(resp, path)
48
+ rescue RemitError => e
49
+ raise unless RETRY_CODES.include?(http_status_for(e)) && attempt < MAX_RETRIES
50
+
51
+ sleep(BASE_DELAY * (2**(attempt - 1)))
52
+ retry
53
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::ReadTimeout => e
54
+ raise RemitError.new(RemitError::NETWORK_ERROR, e.message) if attempt >= MAX_RETRIES
55
+
56
+ sleep(BASE_DELAY * (2**(attempt - 1)))
57
+ retry
58
+ end
59
+ end
60
+
61
+ def build_request(method, path, body)
62
+ full_path = "#{@uri.path}#{path}"
63
+ req = case method
64
+ when :get then Net::HTTP::Get.new(full_path)
65
+ when :post then Net::HTTP::Post.new(full_path)
66
+ end
67
+
68
+ # Generate 32-byte random nonce and Unix timestamp.
69
+ nonce_bytes = SecureRandom.bytes(32)
70
+ nonce_hex = "0x#{nonce_bytes.unpack1("H*")}"
71
+ timestamp = Time.now.to_i
72
+
73
+ # Compute EIP-712 hash and sign it.
74
+ http_method = method.to_s.upcase
75
+ digest = eip712_hash(http_method, full_path, timestamp, nonce_bytes)
76
+ signature = @signer.sign(digest)
77
+
78
+ req["Content-Type"] = "application/json"
79
+ req["Accept"] = "application/json"
80
+ req["X-Remit-Agent"] = @signer.address
81
+ req["X-Remit-Nonce"] = nonce_hex
82
+ req["X-Remit-Timestamp"] = timestamp.to_s
83
+ req["X-Remit-Signature"] = signature
84
+
85
+ if body
86
+ req.body = body.to_json
87
+ end
88
+ req
89
+ end
90
+
91
+ # ─── EIP-712 ──────────────────────────────────────────────────────────────
92
+
93
+ # Computes the EIP-712 hash for an APIRequest struct.
94
+ # Domain: name="remit.md", version="0.1", chainId, verifyingContract
95
+ # Struct: APIRequest(string method, string path, uint256 timestamp, bytes32 nonce)
96
+ def eip712_hash(method, path, timestamp, nonce_bytes)
97
+ # Type hashes (string constants — keccak256 of the type string)
98
+ domain_type_hash = keccak256_bytes(
99
+ "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
100
+ )
101
+ request_type_hash = keccak256_bytes(
102
+ "APIRequest(string method,string path,uint256 timestamp,bytes32 nonce)"
103
+ )
104
+
105
+ # Domain separator
106
+ name_hash = keccak256_bytes("remit.md")
107
+ version_hash = keccak256_bytes("0.1")
108
+ chain_id_enc = abi_uint256(@chain_id)
109
+ contract_enc = abi_address(@router_address)
110
+
111
+ domain_data = domain_type_hash + name_hash + version_hash + chain_id_enc + contract_enc
112
+ domain_separator = keccak256_bytes(domain_data)
113
+
114
+ # Struct hash
115
+ method_hash = keccak256_bytes(method)
116
+ path_hash = keccak256_bytes(path)
117
+ timestamp_enc = abi_uint256(timestamp)
118
+
119
+ struct_data = request_type_hash + method_hash + path_hash + timestamp_enc + nonce_bytes
120
+ struct_hash = keccak256_bytes(struct_data)
121
+
122
+ # Final hash: "\x19\x01" || domainSeparator || structHash
123
+ keccak256_bytes("\x19\x01" + domain_separator + struct_hash)
124
+ end
125
+
126
+ # Encode an integer as a 32-byte big-endian ABI uint256.
127
+ def abi_uint256(value)
128
+ [value.to_i.to_s(16).rjust(64, "0")].pack("H*")
129
+ end
130
+
131
+ # Encode a 20-byte Ethereum address as a 32-byte ABI word (left-zero-padded).
132
+ def abi_address(addr)
133
+ hex = addr.to_s.delete_prefix("0x").rjust(64, "0")
134
+ [hex].pack("H*")
135
+ end
136
+
137
+ # Returns the keccak256 digest as raw binary bytes.
138
+ def keccak256_bytes(data)
139
+ Remitmd::Keccak.digest(data)
140
+ end
141
+
142
+ # ─── Response handling ────────────────────────────────────────────────────
143
+
144
+ def handle_response(resp, path)
145
+ body = resp.body.to_s.strip
146
+ parsed = body.empty? ? {} : JSON.parse(body)
147
+
148
+ status = resp.code.to_i
149
+ case status
150
+ when 200..299
151
+ parsed
152
+ when 400
153
+ code = parsed["code"] || RemitError::SERVER_ERROR
154
+ raise RemitError.new(code, parsed["message"] || "Bad request", context: parsed)
155
+ when 401
156
+ raise RemitError.new(RemitError::UNAUTHORIZED,
157
+ "Authentication failed — check your private key and chain ID")
158
+ when 429
159
+ raise RemitError.new(RemitError::RATE_LIMITED,
160
+ "Rate limit exceeded. See https://remit.md/docs/api-reference/rate-limits")
161
+ when 404
162
+ raise RemitError.new(RemitError::SERVER_ERROR, "Resource not found: #{path}")
163
+ else
164
+ msg = parsed.is_a?(Hash) ? (parsed["message"] || "Server error") : "Server error (#{status})"
165
+ raise RemitError.new(RemitError::SERVER_ERROR, msg, context: parsed)
166
+ end
167
+ rescue JSON::ParserError
168
+ raise RemitError.new(RemitError::SERVER_ERROR, "Invalid JSON response from API")
169
+ end
170
+
171
+ def http_status_for(err)
172
+ case err.code
173
+ when RemitError::RATE_LIMITED then 429
174
+ when RemitError::NETWORK_ERROR then 503
175
+ else 400
176
+ end
177
+ end
178
+
179
+ def build_http(uri)
180
+ http = Net::HTTP.new(uri.host, uri.port)
181
+ http.use_ssl = uri.scheme == "https"
182
+ http.read_timeout = 15
183
+ http.open_timeout = 5
184
+ http
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Remitmd
4
+ # Pure-Ruby Keccak-256 (Ethereum variant — NOT SHA-3).
5
+ #
6
+ # SHA-3 uses different padding (0x06 instead of 0x01).
7
+ # This implementation matches Ethereum's keccak256.
8
+ #
9
+ # Reference: https://keccak.team/keccak_specs_summary.html
10
+ # Rate = 1088 bits (136 bytes), Capacity = 512 bits, Output = 256 bits.
11
+ module Keccak
12
+ RATE_BYTES = 136
13
+ MASK64 = 0xFFFFFFFFFFFFFFFF
14
+
15
+ RC = [
16
+ 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
17
+ 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
18
+ 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
19
+ 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
20
+ 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
21
+ 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008
22
+ ].freeze
23
+
24
+ RHO = [
25
+ 1, 62, 28, 27, 36, 44, 6, 55, 20, 3,
26
+ 10, 43, 25, 39, 41, 45, 15, 21, 8, 18,
27
+ 2, 61, 56, 14
28
+ ].freeze
29
+
30
+ PI = [
31
+ 10, 20, 5, 15, 16, 1, 11, 21, 6, 7,
32
+ 17, 2, 12, 22, 23, 8, 18, 3, 13, 14,
33
+ 24, 9, 19, 4
34
+ ].freeze
35
+
36
+ class << self
37
+ def digest(data)
38
+ data = data.b if data.encoding != Encoding::BINARY
39
+ padded = pad(data)
40
+ state = Array.new(25, 0)
41
+ offset = 0
42
+ while offset < padded.bytesize
43
+ absorb!(state, padded, offset)
44
+ offset += RATE_BYTES
45
+ end
46
+ squeeze(state)
47
+ end
48
+
49
+ def hexdigest(data)
50
+ digest(data).unpack1("H*")
51
+ end
52
+
53
+ private
54
+
55
+ def pad(msg)
56
+ q = RATE_BYTES - (msg.bytesize % RATE_BYTES)
57
+ if q == 1
58
+ msg + "\x81".b
59
+ else
60
+ msg + "\x01".b + ("\x00".b * (q - 2)) + "\x80".b
61
+ end
62
+ end
63
+
64
+ def absorb!(state, data, offset)
65
+ 17.times do |i|
66
+ lane = data.byteslice(offset + i * 8, 8).unpack1("Q<")
67
+ state[i] ^= lane
68
+ end
69
+ keccak_f1600!(state)
70
+ end
71
+
72
+ def squeeze(state)
73
+ state[0, 4].pack("Q<4")
74
+ end
75
+
76
+ def keccak_f1600!(state)
77
+ RC.each { |rc| keccak_round!(state, rc) }
78
+ end
79
+
80
+ def keccak_round!(state, rc) # rubocop:disable Metrics/MethodLength
81
+ # Theta
82
+ c = Array.new(5) do |x|
83
+ state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]
84
+ end
85
+ d = Array.new(5) do |x|
86
+ c[(x + 4) % 5] ^ rotl64(c[(x + 1) % 5], 1)
87
+ end
88
+ 25.times { |i| state[i] ^= d[i % 5] }
89
+
90
+ # Rho + Pi
91
+ b = Array.new(25, 0)
92
+ b[0] = state[0]
93
+ 24.times do |i|
94
+ b[PI[i]] = rotl64(state[i + 1], RHO[i])
95
+ end
96
+
97
+ # Chi
98
+ 5.times do |y|
99
+ row = y * 5
100
+ 5.times do |x|
101
+ idx = row + x
102
+ state[idx] = (b[idx] ^ ((~b[row + (x + 1) % 5]) & b[row + (x + 2) % 5])) & MASK64
103
+ end
104
+ end
105
+
106
+ # Iota
107
+ state[0] = (state[0] ^ rc) & MASK64
108
+ end
109
+
110
+ def rotl64(x, n)
111
+ ((x << n) | (x >> (64 - n))) & MASK64
112
+ end
113
+ end
114
+ end
115
+ end