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 +7 -0
- data/LICENSE +21 -0
- data/README.md +256 -0
- data/lib/remitmd/a2a.rb +58 -0
- data/lib/remitmd/errors.rb +51 -0
- data/lib/remitmd/http.rb +187 -0
- data/lib/remitmd/keccak.rb +115 -0
- data/lib/remitmd/mock.rb +623 -0
- data/lib/remitmd/models.rb +416 -0
- data/lib/remitmd/signer.rb +162 -0
- data/lib/remitmd/wallet.rb +532 -0
- data/lib/remitmd.rb +26 -0
- metadata +87 -0
|
@@ -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
|