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.
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+ require "time"
6
+
7
+ module Remitmd
8
+ # Supported chain IDs
9
+ module ChainId
10
+ BASE = 8453
11
+ BASE_SEPOLIA = 84532
12
+ end
13
+
14
+ # ─── Status types ─────────────────────────────────────────────────────────
15
+
16
+ module EscrowStatus
17
+ PENDING = "pending"
18
+ FUNDED = "funded"
19
+ RELEASED = "released"
20
+ CANCELLED = "cancelled"
21
+ EXPIRED = "expired"
22
+ end
23
+
24
+ module TabStatus
25
+ OPEN = "open"
26
+ CLOSED = "closed"
27
+ SETTLED = "settled"
28
+ end
29
+
30
+ module StreamStatus
31
+ ACTIVE = "active"
32
+ PAUSED = "paused"
33
+ ENDED = "ended"
34
+ CANCELLED = "cancelled"
35
+ end
36
+
37
+ module BountyStatus
38
+ OPEN = "open"
39
+ AWARDED = "awarded"
40
+ EXPIRED = "expired"
41
+ RECLAIMED = "reclaimed"
42
+ end
43
+
44
+ module DepositStatus
45
+ LOCKED = "locked"
46
+ RETURNED = "returned"
47
+ FORFEITED = "forfeited"
48
+ end
49
+
50
+ # ─── Permit & Contract Addresses ─────────────────────────────────────────
51
+
52
+ # EIP-2612 permit signature for gasless USDC approval.
53
+ class PermitSignature
54
+ attr_reader :value, :deadline, :v, :r, :s
55
+
56
+ def initialize(value:, deadline:, v:, r:, s:)
57
+ @value = value
58
+ @deadline = deadline
59
+ @v = v
60
+ @r = r
61
+ @s = s
62
+ end
63
+
64
+ def to_h
65
+ { value: @value, deadline: @deadline, v: @v, r: @r, s: @s }
66
+ end
67
+ end
68
+
69
+ # ─── Value objects ────────────────────────────────────────────────────────
70
+
71
+ # Immutable model base. Builds from a hash with string or symbol keys.
72
+ class Model
73
+ def initialize(attrs)
74
+ attrs.each do |k, v|
75
+ instance_variable_set("@#{k}", v)
76
+ self.class.attr_reader(k.to_sym) unless respond_to?(k.to_sym)
77
+ end
78
+ end
79
+
80
+ def to_h
81
+ instance_variables.each_with_object({}) do |var, h|
82
+ h[var.to_s.delete("@").to_sym] = instance_variable_get(var)
83
+ end
84
+ end
85
+
86
+ def inspect
87
+ "#<#{self.class.name} #{to_h}>"
88
+ end
89
+
90
+ protected
91
+
92
+ def decimal(value)
93
+ return value if value.is_a?(BigDecimal)
94
+ return BigDecimal(value.to_s) if value
95
+ nil
96
+ end
97
+
98
+ def parse_time(value)
99
+ return value if value.is_a?(Time)
100
+ return Time.parse(value) if value.is_a?(String)
101
+ nil
102
+ end
103
+ end
104
+
105
+ # Contract addresses returned by GET /contracts.
106
+ class ContractAddresses < Model
107
+ def initialize(attrs)
108
+ h = attrs.transform_keys(&:to_s)
109
+ @chain_id = h["chain_id"]&.to_i
110
+ @usdc = h["usdc"]
111
+ @router = h["router"]
112
+ @escrow = h["escrow"]
113
+ @tab = h["tab"]
114
+ @stream = h["stream"]
115
+ @bounty = h["bounty"]
116
+ @deposit = h["deposit"]
117
+ @fee_calculator = h["fee_calculator"]
118
+ @key_registry = h["key_registry"]
119
+ @arbitration = h["arbitration"]
120
+ end
121
+
122
+ attr_reader :chain_id, :usdc, :router, :escrow, :tab, :stream,
123
+ :bounty, :deposit, :fee_calculator, :key_registry, :arbitration
124
+ end
125
+
126
+ class Transaction < Model
127
+ def initialize(attrs)
128
+ h = attrs.transform_keys(&:to_s)
129
+ @id = h["id"]
130
+ @tx_hash = h["tx_hash"]
131
+ @from = h["from"]
132
+ @to = h["to"]
133
+ @amount = decimal(h["amount"])
134
+ @fee = decimal(h["fee"] || "0")
135
+ @memo = h["memo"] || ""
136
+ @chain_id = h["chain_id"]&.to_i
137
+ @block_number = h["block_number"]&.to_i || 0
138
+ @created_at = parse_time(h["created_at"])
139
+ end
140
+
141
+ attr_reader :id, :tx_hash, :from, :to, :amount, :fee, :memo,
142
+ :chain_id, :block_number, :created_at
143
+
144
+ private :decimal, :parse_time
145
+ end
146
+
147
+ class Balance < Model
148
+ def initialize(attrs)
149
+ h = attrs.transform_keys(&:to_s)
150
+ @usdc = decimal(h["usdc"])
151
+ @address = h["address"]
152
+ @chain_id = h["chain_id"]&.to_i
153
+ @updated_at = parse_time(h["updated_at"])
154
+ end
155
+
156
+ attr_reader :usdc, :address, :chain_id, :updated_at
157
+
158
+ private :decimal, :parse_time
159
+ end
160
+
161
+ class Reputation < Model
162
+ def initialize(attrs)
163
+ h = attrs.transform_keys(&:to_s)
164
+ @address = h["address"]
165
+ @score = h["score"]&.to_i || 0
166
+ @total_paid = decimal(h["total_paid"])
167
+ @total_received = decimal(h["total_received"])
168
+ @transaction_count = h["transaction_count"]&.to_i || 0
169
+ @member_since = parse_time(h["member_since"])
170
+ end
171
+
172
+ attr_reader :address, :score, :total_paid, :total_received,
173
+ :transaction_count, :member_since
174
+
175
+ private :decimal, :parse_time
176
+ end
177
+
178
+ class Escrow < Model
179
+ def initialize(attrs)
180
+ h = attrs.transform_keys(&:to_s)
181
+ @id = h["invoice_id"] || h["id"]
182
+ @payer = h["payer"]
183
+ @payee = h["payee"]
184
+ @amount = decimal(h["amount"])
185
+ @fee = decimal(h["fee"] || "0")
186
+ @status = h["status"]
187
+ @memo = h["memo"] || ""
188
+ @milestones = h["milestones"] || []
189
+ @splits = h["splits"] || []
190
+ @expires_at = parse_time(h["expires_at"])
191
+ @created_at = parse_time(h["created_at"])
192
+ end
193
+
194
+ attr_reader :id, :payer, :payee, :amount, :fee, :status,
195
+ :memo, :milestones, :splits, :expires_at, :created_at
196
+
197
+ private :decimal, :parse_time
198
+ end
199
+
200
+ class Tab < Model
201
+ def initialize(attrs)
202
+ h = attrs.transform_keys(&:to_s)
203
+ @id = h["id"]
204
+ @opener = h["opener"] || h["payer"]
205
+ @provider = h["provider"] || h["counterpart"]
206
+ @limit = decimal(h["limit_amount"] || h["limit"])
207
+ @used = decimal(h["used"] || "0")
208
+ @remaining = decimal(h["remaining"] || h["limit_amount"] || h["limit"])
209
+ @status = h["status"]
210
+ @created_at = parse_time(h["created_at"])
211
+ @closes_at = parse_time(h["closes_at"])
212
+ end
213
+
214
+ attr_reader :id, :opener, :provider, :limit, :used, :remaining,
215
+ :status, :created_at, :closes_at
216
+
217
+ # Backward compatibility alias
218
+ alias counterpart provider
219
+
220
+ private :decimal, :parse_time
221
+ end
222
+
223
+ class TabDebit < Model
224
+ def initialize(attrs)
225
+ h = attrs.transform_keys(&:to_s)
226
+ @tab_id = h["tab_id"]
227
+ @amount = decimal(h["amount"])
228
+ @cumulative = decimal(h["cumulative"])
229
+ @call_count = h["call_count"]&.to_i || 0
230
+ @memo = h["memo"] || ""
231
+ @sequence = h["sequence"]&.to_i || 0
232
+ @signature = h["signature"]
233
+ end
234
+
235
+ attr_reader :tab_id, :amount, :cumulative, :call_count, :memo, :sequence, :signature
236
+
237
+ private :decimal
238
+ end
239
+
240
+ class Stream < Model
241
+ def initialize(attrs)
242
+ h = attrs.transform_keys(&:to_s)
243
+ @id = h["id"]
244
+ @sender = h["sender"]
245
+ @recipient = h["recipient"]
246
+ @rate_per_sec = decimal(h["rate_per_sec"])
247
+ @deposited = decimal(h["deposited"])
248
+ @withdrawn = decimal(h["withdrawn"] || "0")
249
+ @status = h["status"]
250
+ @started_at = parse_time(h["started_at"])
251
+ @ends_at = parse_time(h["ends_at"])
252
+ end
253
+
254
+ attr_reader :id, :sender, :recipient, :rate_per_sec, :deposited,
255
+ :withdrawn, :status, :started_at, :ends_at
256
+
257
+ private :decimal, :parse_time
258
+ end
259
+
260
+ class Bounty < Model
261
+ def initialize(attrs)
262
+ h = attrs.transform_keys(&:to_s)
263
+ @id = h["id"]
264
+ @poster = h["poster"]
265
+ @amount = decimal(h["amount"] || h["award"])
266
+ @task_description = h["task_description"] || h["description"]
267
+ @status = h["status"]
268
+ @winner = h["winner"] || ""
269
+ @expires_at = parse_time(h["expires_at"])
270
+ @created_at = parse_time(h["created_at"])
271
+ end
272
+
273
+ attr_reader :id, :poster, :amount, :task_description, :status,
274
+ :winner, :expires_at, :created_at
275
+
276
+ # Backward compatibility aliases
277
+ alias award amount
278
+ alias description task_description
279
+
280
+ private :decimal, :parse_time
281
+ end
282
+
283
+ class BountySubmission < Model
284
+ def initialize(attrs)
285
+ h = attrs.transform_keys(&:to_s)
286
+ @id = h["id"]
287
+ @bounty_id = h["bounty_id"]
288
+ @submitter = h["submitter"]
289
+ @evidence_hash = h["evidence_hash"]
290
+ @status = h["status"]
291
+ @created_at = parse_time(h["created_at"])
292
+ end
293
+
294
+ attr_reader :id, :bounty_id, :submitter, :evidence_hash, :status, :created_at
295
+
296
+ private :parse_time
297
+ end
298
+
299
+ class Deposit < Model
300
+ def initialize(attrs)
301
+ h = attrs.transform_keys(&:to_s)
302
+ @id = h["id"]
303
+ @depositor = h["depositor"] || h["payer"]
304
+ @provider = h["provider"] || h["beneficiary"]
305
+ @amount = decimal(h["amount"])
306
+ @status = h["status"]
307
+ @expires_at = parse_time(h["expires_at"])
308
+ @created_at = parse_time(h["created_at"])
309
+ end
310
+
311
+ attr_reader :id, :depositor, :provider, :amount,
312
+ :status, :expires_at, :created_at
313
+
314
+ # Backward compatibility alias
315
+ alias beneficiary provider
316
+
317
+ private :decimal, :parse_time
318
+ end
319
+
320
+ class Intent < Model
321
+ def initialize(attrs)
322
+ h = attrs.transform_keys(&:to_s)
323
+ @id = h["id"]
324
+ @from = h["from"]
325
+ @to = h["to"]
326
+ @amount = decimal(h["amount"])
327
+ @type = h["type"]
328
+ @expires_at = parse_time(h["expires_at"])
329
+ @created_at = parse_time(h["created_at"])
330
+ end
331
+
332
+ attr_reader :id, :from, :to, :amount, :type, :expires_at, :created_at
333
+
334
+ private :decimal, :parse_time
335
+ end
336
+
337
+ class Budget < Model
338
+ def initialize(attrs)
339
+ h = attrs.transform_keys(&:to_s)
340
+ @daily_limit = decimal(h["daily_limit"])
341
+ @daily_used = decimal(h["daily_used"])
342
+ @daily_remaining = decimal(h["daily_remaining"])
343
+ @monthly_limit = decimal(h["monthly_limit"])
344
+ @monthly_used = decimal(h["monthly_used"])
345
+ @monthly_remaining = decimal(h["monthly_remaining"])
346
+ @per_tx_limit = decimal(h["per_tx_limit"])
347
+ end
348
+
349
+ attr_reader :daily_limit, :daily_used, :daily_remaining,
350
+ :monthly_limit, :monthly_used, :monthly_remaining, :per_tx_limit
351
+
352
+ private :decimal
353
+ end
354
+
355
+ class SpendingSummary < Model
356
+ def initialize(attrs)
357
+ h = attrs.transform_keys(&:to_s)
358
+ @address = h["address"]
359
+ @period = h["period"]
360
+ @total_spent = decimal(h["total_spent"])
361
+ @total_fees = decimal(h["total_fees"])
362
+ @tx_count = h["tx_count"]&.to_i || 0
363
+ @top_recipients = h["top_recipients"] || []
364
+ end
365
+
366
+ attr_reader :address, :period, :total_spent, :total_fees,
367
+ :tx_count, :top_recipients
368
+
369
+ private :decimal
370
+ end
371
+
372
+ # One-time operator link for funding or withdrawing.
373
+ class LinkResponse < Model
374
+ def initialize(attrs)
375
+ h = attrs.transform_keys(&:to_s)
376
+ @url = h["url"]
377
+ @token = h["token"]
378
+ @expires_at = h["expires_at"]
379
+ @wallet_address = h["wallet_address"]
380
+ end
381
+
382
+ attr_reader :url, :token, :expires_at, :wallet_address
383
+ end
384
+
385
+ class Webhook < Model
386
+ def initialize(attrs)
387
+ h = attrs.transform_keys(&:to_s)
388
+ @id = h["id"]
389
+ @wallet = h["wallet"]
390
+ @url = h["url"]
391
+ @events = h["events"] || []
392
+ @chains = h["chains"] || []
393
+ @active = h["active"] == true
394
+ @created_at = parse_time(h["created_at"])
395
+ @updated_at = parse_time(h["updated_at"])
396
+ end
397
+
398
+ attr_reader :id, :wallet, :url, :events, :chains, :active, :created_at, :updated_at
399
+
400
+ private :parse_time
401
+ end
402
+
403
+ class TransactionList < Model
404
+ def initialize(attrs)
405
+ h = attrs.transform_keys(&:to_s)
406
+ items_raw = h["items"] || []
407
+ @items = items_raw.map { |tx| Transaction.new(tx) }
408
+ @total = h["total"]&.to_i || 0
409
+ @page = h["page"]&.to_i || 1
410
+ @per_page = h["per_page"]&.to_i || 50
411
+ @has_more = h["has_more"] == true
412
+ end
413
+
414
+ attr_reader :items, :total, :page, :per_page, :has_more
415
+ end
416
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module Remitmd
7
+ # secp256k1 field prime p (constant — never changes)
8
+ SECP256K1_P = OpenSSL::BN.new(
9
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16
10
+ ).freeze
11
+
12
+ # Precomputed (p + 1) / 4 — the modular square root exponent for p ≡ 3 (mod 4).
13
+ # Avoids BN division at runtime (which returns Integer on some OpenSSL versions).
14
+ SECP256K1_SQRT_EXP = OpenSSL::BN.new(
15
+ "3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFBFFFFF0C", 16
16
+ ).freeze
17
+
18
+ # Interface for signing remit.md API requests.
19
+ # Implement this module to provide custom signing (HSM, KMS, etc.).
20
+ module Signer
21
+ # Sign a 32-byte digest (raw binary). Returns 65-byte hex (r||s||v, Ethereum style).
22
+ def sign(digest)
23
+ raise NotImplementedError, "#{self.class}#sign is not implemented"
24
+ end
25
+
26
+ # The Ethereum address corresponding to the signing key (0x-prefixed).
27
+ def address
28
+ raise NotImplementedError, "#{self.class}#address is not implemented"
29
+ end
30
+ end
31
+
32
+ # Signs remit.md API requests using a raw secp256k1 private key.
33
+ # The private key is held in memory and never exposed via public methods.
34
+ class PrivateKeySigner
35
+ include Signer
36
+
37
+ def initialize(private_key_hex)
38
+ hex = private_key_hex.to_s.delete_prefix("0x")
39
+ raise ArgumentError, "Private key must be 64 hex characters" unless hex.match?(/\A[0-9a-fA-F]{64}\z/)
40
+
41
+ key_bytes = [hex].pack("H*")
42
+ group = OpenSSL::PKey::EC::Group.new("secp256k1")
43
+ bn = OpenSSL::BN.new(key_bytes, 2)
44
+ pub_point = group.generator.mul(bn)
45
+
46
+ # Build SEC1 DER-encoded private key (OpenSSL 3.x: PKey objects are immutable)
47
+ asn1 = OpenSSL::ASN1::Sequence.new([
48
+ OpenSSL::ASN1::Integer.new(1),
49
+ OpenSSL::ASN1::OctetString.new(key_bytes),
50
+ OpenSSL::ASN1::ASN1Data.new(
51
+ [OpenSSL::ASN1::ObjectId.new("secp256k1")], 0, :CONTEXT_SPECIFIC
52
+ ),
53
+ OpenSSL::ASN1::ASN1Data.new(
54
+ [OpenSSL::ASN1::BitString.new(pub_point.to_octet_string(:uncompressed))],
55
+ 1, :CONTEXT_SPECIFIC
56
+ )
57
+ ])
58
+ @key = OpenSSL::PKey::EC.new(asn1.to_der)
59
+ @address = derive_address(pub_point)
60
+ end
61
+
62
+ # Sign a 32-byte EIP-712 digest (raw binary bytes).
63
+ # Returns a 0x-prefixed 65-byte hex signature (r || s || v) in Ethereum style.
64
+ # v is 27 (0x1b) or 28 (0x1c).
65
+ def sign(digest_bytes)
66
+ group = @key.group
67
+ n = group.order
68
+
69
+ # ECDSA sign — dsa_sign_asn1 uses the input directly as the hash (no pre-hashing)
70
+ der = @key.dsa_sign_asn1(digest_bytes)
71
+ asn1 = OpenSSL::ASN1.decode(der)
72
+ bn_r = asn1.value[0].value
73
+ bn_s = asn1.value[1].value
74
+
75
+ # Compute the recovery ID (0 or 1) by checking which candidate R recovers our address.
76
+ z = OpenSSL::BN.new(digest_bytes.unpack1("H*"), 16)
77
+ r_inv = bn_r.mod_inverse(n)
78
+ # Q_candidate = r_inv * (s * R - z * G) = (r_inv*s)*R + (-(r_inv*z))*G
79
+ a = r_inv.mod_mul(bn_s, n)
80
+ b = n - r_inv.mod_mul(z, n) # -r_inv*z mod n
81
+
82
+ # Normalize s to low-s canonical form (required by the server's k256 verifier).
83
+ # k256::ecdsa::Signature::from_slice rejects signatures with s > n/2.
84
+ half_n = n >> 1
85
+ if bn_s > half_n
86
+ bn_s = n - bn_s
87
+ # Recompute a and b for the new s
88
+ a = r_inv.mod_mul(bn_s, n)
89
+ b = n - r_inv.mod_mul(z, n)
90
+ end
91
+
92
+ v = nil
93
+ [0, 1].each do |parity|
94
+ r_point = recover_r_point(group, bn_r, parity)
95
+ next unless r_point
96
+
97
+ # Q = b*G + a*R (separate mul + add for OpenSSL 3.x compatibility)
98
+ bg = group.generator.mul(b)
99
+ ar = r_point.mul(a)
100
+ q = bg.add(ar)
101
+ candidate = derive_address(q)
102
+ if candidate.downcase == @address.downcase
103
+ v = 27 + parity
104
+ break
105
+ end
106
+ end
107
+ raise "Could not determine recovery ID — key or hash may be invalid" if v.nil?
108
+
109
+ # Build 65-byte Ethereum signature: r (32) || s (32) || v (1)
110
+ r_bytes = [bn_r.to_s(16).rjust(64, "0")].pack("H*")
111
+ s_bytes = [bn_s.to_s(16).rjust(64, "0")].pack("H*")
112
+ "0x#{(r_bytes + s_bytes + v.chr(Encoding::BINARY)).unpack1("H*")}"
113
+ end
114
+
115
+ # The Ethereum address (checksummed, 0x-prefixed).
116
+ attr_reader :address
117
+
118
+ # Never expose the key in inspect/to_s output.
119
+ def inspect
120
+ "#<Remitmd::PrivateKeySigner address=#{@address}>"
121
+ end
122
+
123
+ alias to_s inspect
124
+
125
+ private
126
+
127
+ # Recover the secp256k1 point R from r (big integer) and y-parity (0=even, 1=odd).
128
+ # Returns nil if the point is invalid.
129
+ def recover_r_point(group, bn_r, parity)
130
+ p = SECP256K1_P
131
+ x = bn_r
132
+ # y² = x³ + 7 (mod p) — secp256k1 curve equation
133
+ x3 = x.mod_exp(OpenSSL::BN.new("3"), p)
134
+ rhs = x3 + OpenSSL::BN.new("7")
135
+ y_squared = rhs % p
136
+ # Tonelli–Shanks: since p ≡ 3 mod 4, sqrt = y²^((p+1)/4) mod p
137
+ y = y_squared.mod_exp(SECP256K1_SQRT_EXP, p)
138
+ # Verify that y² ≡ y_squared (mod p) — i.e., a square root exists
139
+ return nil unless y.mod_mul(y, p) == y_squared
140
+
141
+ y = p - y if (y.to_i & 1) != parity
142
+
143
+ hex_x = x.to_s(16).rjust(64, "0")
144
+ hex_y = y.to_s(16).rjust(64, "0")
145
+ OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("04#{hex_x}#{hex_y}", 16))
146
+ rescue OpenSSL::PKey::ECError
147
+ nil
148
+ end
149
+
150
+ def derive_address(public_key)
151
+ # Uncompressed public key: 04 || x (32) || y (32) — skip the 0x04 prefix
152
+ pub_bytes = [public_key.to_octet_string(:uncompressed).unpack1("H*")[2..]].pack("H*")
153
+ keccak = keccak256_hex(pub_bytes)
154
+ "0x#{keccak[-40..]}"
155
+ end
156
+
157
+ # Returns the keccak256 digest as a hex string (no 0x prefix).
158
+ def keccak256_hex(data)
159
+ Remitmd::Keccak.hexdigest(data)
160
+ end
161
+ end
162
+ end