rail0-sdk 1.0.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,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query"
4
+
5
+ module Rail0
6
+ module Resources
7
+ # Webhook subscription management (requires JWT).
8
+ #
9
+ # A subscription covers a SET of topics — one shared secret and one circuit breaker
10
+ # for all of them — and each delivery names the event that fired in `X-Rail0-Topic`.
11
+ # Two subscriptions for the same callback_url must not overlap: one event delivered
12
+ # twice under two different secrets is indistinguishable from a duplicate at the
13
+ # receiving end, so the gateway answers 409 and names the topic that collided.
14
+ # See {TOPICS} for the accepted values.
15
+ class Webhooks
16
+ include Query
17
+
18
+ # Event topics a subscription can carry. It may carry any non-empty subset.
19
+ TOPICS = %w[
20
+ payments.created
21
+ payments.signed
22
+ payments.authorized
23
+ payments.charged
24
+ payments.captured
25
+ payments.voided
26
+ payments.released
27
+ payments.refunded
28
+ payments.expired
29
+ payments.failed
30
+ payments.disputed
31
+ payments.dispute_closed
32
+ ].freeze
33
+
34
+ attr_reader :http
35
+
36
+ def initialize(http)
37
+ @http = http
38
+ freeze
39
+ end
40
+
41
+ # List the account's webhooks.
42
+ # @param topic [String, nil] Narrow to subscriptions that INCLUDE this event
43
+ # (see {TOPICS}). Singular on purpose: the question is which subscriptions
44
+ # deliver one event, whatever else they also deliver.
45
+ # @param active [Boolean, nil] Filter by active flag.
46
+ # @param circuit_state [String, nil] Filter by circuit state ("closed" or "open").
47
+ # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
48
+ # @param page [Integer, nil] Page number (1-based).
49
+ # @param per_page [Integer, nil] Items per page (max 100).
50
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
51
+ def list(topic: nil, active: nil, circuit_state: nil, sort: nil, page: nil, per_page: nil)
52
+ query = build_query(topic: topic, active: active, circuit_state: circuit_state,
53
+ sort: sort, page: page, per_page: per_page)
54
+ http.get_list("/webhooks#{query}")
55
+ end
56
+
57
+ # Register a new webhook. The response includes the one-time shared_secret
58
+ # used to verify delivery signatures — it is shown only on create and rotate.
59
+ # @param name [String] Human-readable name.
60
+ # @param callback_url [String] HTTPS URL the gateway POSTs events to.
61
+ # @param topics [Array<String>] One or more of {TOPICS}. Repeats are collapsed by
62
+ # the gateway; overlapping another subscription on the same callback_url is a 409
63
+ # naming the topic that collided.
64
+ # @return [Hash] webhook record including shared_secret
65
+ def create(name:, callback_url:, topics:)
66
+ http.post("/webhooks", { name: name, callback_url: callback_url, topics: Array(topics) })
67
+ end
68
+
69
+ # Fetch a single webhook.
70
+ # @param id [String] Webhook UUID.
71
+ # @return [Hash]
72
+ def get(id)
73
+ http.get("/webhooks/#{id}")
74
+ end
75
+
76
+ # Update a webhook's name, callback_url, and/or topics.
77
+ # @param id [String] Webhook UUID.
78
+ # @param name [String, nil]
79
+ # @param callback_url [String, nil]
80
+ # @param topics [Array<String>, nil] REPLACES the whole set, which is also how a
81
+ # topic is removed: send the union to add one, the remainder to drop one. The
82
+ # shared secret is untouched.
83
+ # @return [Hash]
84
+ def update(id, name: nil, callback_url: nil, topics: nil)
85
+ body = {}
86
+ body[:name] = name unless name.nil?
87
+ body[:callback_url] = callback_url unless callback_url.nil?
88
+ body[:topics] = Array(topics) unless topics.nil?
89
+ http.patch("/webhooks/#{id}", body)
90
+ end
91
+
92
+ # Re-enable a disabled webhook.
93
+ # @param id [String] Webhook UUID.
94
+ # @return [Hash]
95
+ def enable(id)
96
+ http.put("/webhooks/#{id}/enable")
97
+ end
98
+
99
+ # Disable a webhook (stops deliveries without deleting it).
100
+ # @param id [String] Webhook UUID.
101
+ # @return [Hash]
102
+ def disable(id)
103
+ http.put("/webhooks/#{id}/disable")
104
+ end
105
+
106
+ # Generate a new shared secret, returned once on the response.
107
+ # @param id [String] Webhook UUID.
108
+ # @return [Hash] webhook record including the new shared_secret
109
+ def rotate_secret(id)
110
+ http.put("/webhooks/#{id}/rotate_secret")
111
+ end
112
+
113
+ # Reset the delivery circuit breaker and re-enable the webhook.
114
+ # @param id [String] Webhook UUID.
115
+ # @return [Hash]
116
+ def reset_circuit(id)
117
+ http.put("/webhooks/#{id}/reset_circuit")
118
+ end
119
+
120
+ # List delivery attempts for a webhook.
121
+ # @param id [String] Webhook UUID.
122
+ # @param status [String, nil] Filter by delivery status ("pending", "delivered", "failed").
123
+ # @param topic [String, nil] Filter by event topic.
124
+ # @param payment_id [String, nil] Filter by the payment the delivery is for.
125
+ # @param since [String, nil] Only deliveries at/after this ISO-8601 time.
126
+ # @param until_time [String, nil] Only deliveries at/before this ISO-8601 time (query key: "until").
127
+ # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc.
128
+ # @param page [Integer, nil] Page number (1-based).
129
+ # @param per_page [Integer, nil] Items per page (max 100).
130
+ # @return [Hash] { data: Array<Hash>, meta: { page:, per_page:, total: } }
131
+ def event_callbacks(id, status: nil, topic: nil, payment_id: nil, since: nil,
132
+ until_time: nil, sort: nil, page: nil, per_page: nil)
133
+ query = build_query(status: status, topic: topic, payment_id: payment_id, since: since,
134
+ until: until_time, sort: sort, page: page, per_page: per_page)
135
+ http.get_list("/webhooks/#{id}/event_callbacks#{query}")
136
+ end
137
+
138
+ # Delete a webhook. Returns HTTP 204.
139
+ # @param id [String] Webhook UUID.
140
+ # @return [nil]
141
+ def delete(id)
142
+ http.delete("/webhooks/#{id}")
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ begin
6
+ original_verbose = $VERBOSE
7
+ $VERBOSE = nil
8
+ require "eth"
9
+ rescue LoadError => e
10
+ raise e,
11
+ "Rail0::Signing requires the 'eth' gem. Add `gem 'eth', '~> 0.5'` to your Gemfile."
12
+ ensure
13
+ $VERBOSE = original_verbose
14
+ end
15
+
16
+ module Rail0
17
+ # EIP-712 and EIP-3009 signing utilities for RAIL0 payments.
18
+ #
19
+ # Requires the optional signing dependency:
20
+ # gem 'eth', '~> 0.5'
21
+ #
22
+ # No private key is ever sent to the API — signatures are built off-chain
23
+ # and included in the request body.
24
+ #
25
+ # ## Typical usage (simplest path)
26
+ #
27
+ # resp = client.payments.create(chain_id: 84532, mode: "authorize", amount: "100.00", token: "0x...", payer: "0x...", payee: "0x...")
28
+ # sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, resp[:signing_payload])
29
+ # client.payments.sign(resp[:rail0_id], { signature: sig.to_hex })
30
+ #
31
+ module Signing
32
+ # EIP-712 domain of the ERC-20 token (NOT the RAIL0 contract).
33
+ TokenDomain = Struct.new(:name, :version, :chain_id, :verifying_contract, keyword_init: true)
34
+
35
+ # EIP-3009 transferWithAuthorization signature.
36
+ # Call {to_hex} to assemble the 65-byte hex string expected by `PUT /payments/{id}/sign`.
37
+ Eip3009Signature = Struct.new(:v, :r, :s, keyword_init: true) do
38
+ # Encodes the signature as a 0x-prefixed 65-byte hex string (r ++ s ++ v).
39
+ # This is the format expected by the `signature` field of PayerSignatureRequest.
40
+ #
41
+ # @return [String] "0x" + r (32 bytes) + s (32 bytes) + v (1 byte), 132 chars total.
42
+ def to_hex
43
+ raise ArgumentError, "r and s must be 0x-prefixed hex strings" unless r.start_with?("0x") && s.start_with?("0x")
44
+
45
+ "0x#{r[2..]}#{s[2..]}#{v.to_s(16).rjust(2, '0')}"
46
+ end
47
+ end
48
+
49
+ # Parameters for a raw transferWithAuthorization signature.
50
+ SignTransferParams = Struct.new(
51
+ :from, :to, :value, :valid_before, :nonce,
52
+ :valid_after,
53
+ keyword_init: true
54
+ ) do
55
+ def initialize(**)
56
+ super
57
+ self.valid_after ||= 0
58
+ end
59
+ end
60
+
61
+ # DEPRECATED — the parameter object of the removed {sign_authorize} /
62
+ # {sign_charge}. Kept only so existing code reaches those methods' migration
63
+ # message instead of a NameError while building their arguments; nothing in this
64
+ # SDK consumes it. Use {sign_payload} with the gateway's `signing_payload`.
65
+ SignPaymentParams = Struct.new(
66
+ :private_key, :payment, :nonce, :contract_address, :token_domain,
67
+ keyword_init: true
68
+ )
69
+
70
+ DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
71
+ TRANSFER_TYPE = "TransferWithAuthorization(address from,address to,uint256 value," \
72
+ "uint256 validAfter,uint256 validBefore,bytes32 nonce)"
73
+ RECEIVE_TYPE = "ReceiveWithAuthorization(address from,address to,uint256 value," \
74
+ "uint256 validAfter,uint256 validBefore,bytes32 nonce)"
75
+
76
+ DOMAIN_TYPEHASH = Eth::Util.keccak256(DOMAIN_TYPE)
77
+ TRANSFER_TYPEHASH = Eth::Util.keccak256(TRANSFER_TYPE)
78
+ RECEIVE_TYPEHASH = Eth::Util.keccak256(RECEIVE_TYPE)
79
+
80
+ private_constant :DOMAIN_TYPE, :TRANSFER_TYPE, :RECEIVE_TYPE,
81
+ :DOMAIN_TYPEHASH, :TRANSFER_TYPEHASH, :RECEIVE_TYPEHASH
82
+
83
+ # Primary types the gateway can ask us to sign. Which one a payment needs is
84
+ # the gateway's business: it builds its client per-payment with the deployment's
85
+ # contract_version, and the version selects the typehash, the domain and the
86
+ # field layout.
87
+ PRIMARY_TYPES = {
88
+ "TransferWithAuthorization" => TRANSFER_TYPEHASH,
89
+ "ReceiveWithAuthorization" => RECEIVE_TYPEHASH
90
+ }.freeze
91
+ private_constant :PRIMARY_TYPES
92
+
93
+ # Resolve a payload's primaryType to its typehash, or refuse.
94
+ #
95
+ # NEVER guess. This used to be a ternary that fell back to the Transfer
96
+ # typehash for anything that wasn't the exact string "ReceiveWithAuthorization"
97
+ # — including a nil, a typo, or a primary type introduced by a newer gateway.
98
+ # The result is a perfectly well-formed signature over the WRONG digest: the
99
+ # call reports success, and the failure surfaces only on-chain, after gas,
100
+ # where it looks like a bad key. An unknown type means this SDK is older than
101
+ # the gateway it is talking to, which is a diagnosable condition. (#7)
102
+ def self.typehash_for(primary_type)
103
+ PRIMARY_TYPES.fetch(primary_type.to_s) do
104
+ raise ArgumentError,
105
+ "unsupported signing payload primaryType #{primary_type.inspect} " \
106
+ "(expected one of #{PRIMARY_TYPES.keys.join(', ')}) — this SDK is older " \
107
+ "than the gateway it is talking to; upgrade rail0-ruby"
108
+ end
109
+ end
110
+
111
+ # Accept a payload whose keys are Strings as well as Symbols: callers who parse
112
+ # the gateway's JSON themselves get string keys, and every lookup here is by
113
+ # symbol. Silently returning nils for a string-keyed payload is how a caller
114
+ # ends up signing a digest full of blanks.
115
+ def self.symbolize(hash)
116
+ raise ArgumentError, "signing payload must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
117
+
118
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
119
+ end
120
+
121
+ private_class_method :typehash_for, :symbolize
122
+
123
+ def self.hex_to_bytes(hex)
124
+ h = hex.start_with?("0x") ? hex[2..] : hex
125
+ [h].pack("H*")
126
+ end
127
+
128
+ def self.abi_address(address)
129
+ ("\x00" * 12) + hex_to_bytes(address)
130
+ end
131
+
132
+ def self.uint256_to_bytes32(value)
133
+ hex = Integer(value).to_s(16).rjust(64, "0")
134
+ [hex].pack("H*")
135
+ end
136
+
137
+ def self.bytes_to_hex(bytes)
138
+ "0x#{bytes.unpack1('H*')}"
139
+ end
140
+
141
+ private_class_method :hex_to_bytes, :abi_address, :uint256_to_bytes32, :bytes_to_hex
142
+
143
+ def self.hash_domain(domain)
144
+ Eth::Util.keccak256(
145
+ DOMAIN_TYPEHASH +
146
+ Eth::Util.keccak256(domain.name) +
147
+ Eth::Util.keccak256(domain.version) +
148
+ uint256_to_bytes32(domain.chain_id) +
149
+ abi_address(domain.verifying_contract)
150
+ )
151
+ end
152
+
153
+ def self.hash_struct(from:, to:, value:, valid_after:, valid_before:, nonce:, typehash:)
154
+ Eth::Util.keccak256(
155
+ typehash +
156
+ abi_address(from) +
157
+ abi_address(to) +
158
+ uint256_to_bytes32(value) +
159
+ uint256_to_bytes32(valid_after) +
160
+ uint256_to_bytes32(valid_before) +
161
+ hex_to_bytes(nonce)
162
+ )
163
+ end
164
+
165
+ def self.build_digest(domain, from:, to:, value:, valid_after:, valid_before:, nonce:, typehash:)
166
+ # rubocop:disable Style/StringConcatenation -- these are BINARY byte strings and the
167
+ # EIP-712 preimage is defined as their concatenation: `+` says that, while
168
+ # interpolation would coerce each part through to_s and read as text assembly.
169
+ Eth::Util.keccak256(
170
+ "\x19\x01" +
171
+ hash_domain(domain) +
172
+ hash_struct(from: from, to: to, value: value, valid_after: valid_after,
173
+ valid_before: valid_before, nonce: nonce, typehash: typehash)
174
+ )
175
+ # rubocop:enable Style/StringConcatenation
176
+ end
177
+
178
+ private_class_method :hash_domain, :hash_struct, :build_digest
179
+
180
+ # Resolve whatever the caller passed into something that can sign a digest.
181
+ #
182
+ # The seam exists so the raw secret does not have to be materialised as a Ruby
183
+ # String in the calling process: pass a private-key String (unchanged), an
184
+ # Eth::Key, or ANY object responding to #sign(digest) -> 65-byte hex — a KMS or
185
+ # HSM client, a remote signer, a hardware wallet bridge. This SDK builds the
186
+ # EIP-712 digest and hands only that over; it never needs the key material
187
+ # itself, which is what makes "the server must never hold the buyer's key"
188
+ # implementable rather than aspirational. (#10)
189
+ def self.signer_for(private_key)
190
+ return private_key if private_key.respond_to?(:sign)
191
+
192
+ unless private_key.is_a?(String)
193
+ raise ArgumentError,
194
+ "expected a private key String or an object responding to #sign(digest), " \
195
+ "got #{private_key.class}"
196
+ end
197
+
198
+ Eth::Key.new(priv: private_key.delete_prefix("0x"))
199
+ end
200
+
201
+ private_class_method :signer_for
202
+
203
+ def self.do_sign(private_key, domain, from:, to:, value:, valid_after:, valid_before:, nonce:, typehash:)
204
+ digest = build_digest(domain, from: from, to: to, value: value, valid_after: valid_after,
205
+ valid_before: valid_before, nonce: nonce, typehash: typehash)
206
+
207
+ sig = signer_for(private_key).sign(digest)
208
+ sig_bytes = [sig.to_s.delete_prefix("0x")].pack("H*")
209
+
210
+ # A custom signer that returns something else would otherwise produce a
211
+ # signature with v: nil, which only fails later in #to_hex with nothing
212
+ # pointing at the signer.
213
+ unless sig_bytes.bytesize == 65
214
+ raise ArgumentError,
215
+ "the signer returned #{sig_bytes.bytesize} bytes, expected 65 " \
216
+ "(r || s || v as hex)"
217
+ end
218
+
219
+ Eip3009Signature.new(
220
+ v: sig_bytes.getbyte(64),
221
+ r: bytes_to_hex(sig_bytes[0, 32]),
222
+ s: bytes_to_hex(sig_bytes[32, 32])
223
+ )
224
+ end
225
+
226
+ private_class_method :do_sign
227
+
228
+ # Build and sign the EIP-1559 (type-2) transaction described by a prepare
229
+ # step's +unsigned_transaction+ and return the signed raw transaction as a
230
+ # 0x-prefixed hex string, ready for the matching submit call.
231
+ #
232
+ # The gateway never holds private keys: it returns the transaction *fields*
233
+ # (chain id, nonce, to, value, data, gas, fees) and the client assembles and
234
+ # signs the transaction locally.
235
+ #
236
+ # tx = client.payments.authorize_prepare(rail0_id)
237
+ # raw = Rail0::Signing.sign_transaction(tx[:unsigned_transaction], PAYER_PRIVATE_KEY)
238
+ # client.payments.authorize(rail0_id, { signed_transaction: raw })
239
+ #
240
+ # @param unsigned_transaction [String, Hash] The +unsigned_transaction+ JSON
241
+ # string from a prepare response (a pre-parsed Hash is also accepted).
242
+ # @param private_key [String] Signer's private key (0x-prefixed or raw hex).
243
+ # @return [String] 0x-prefixed RLP-encoded signed transaction.
244
+ def self.sign_transaction(unsigned_transaction, private_key)
245
+ f = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
246
+ f = f.transform_keys(&:to_s)
247
+
248
+ tx = Eth::Tx.new(
249
+ chain_id: Integer(f.fetch("chain_id")),
250
+ nonce: Integer(f.fetch("nonce")),
251
+ priority_fee: Integer(f.fetch("max_priority_fee_per_gas")),
252
+ max_gas_fee: Integer(f.fetch("max_fee_per_gas")),
253
+ gas_limit: Integer(f.fetch("gas_limit")),
254
+ to: f.fetch("to"),
255
+ value: Integer(f["value"] || 0),
256
+ data: f["data"].to_s
257
+ )
258
+
259
+ # Deliberately narrower than the digest signers above: Eth::Tx#sign needs a
260
+ # full Eth::Key (it asks for an EIP-155 v derived from the chain id, not a bare
261
+ # digest signature), so an arbitrary #sign(digest) object cannot serve here.
262
+ # An Eth::Key is accepted so a caller holding one doesn't have to export it
263
+ # back to a String.
264
+ tx.sign(private_key.is_a?(Eth::Key) ? private_key : Eth::Key.new(priv: private_key.delete_prefix("0x")))
265
+ "0x#{tx.hex}"
266
+ end
267
+
268
+ # Sign the EIP-3009 payload using the signing_payload returned by POST /payments.
269
+ #
270
+ # This is the simplest entry point: pass the full signing_payload from the create response
271
+ # and a private key — all fields are read directly from the payload without any manual
272
+ # reconstruction. (The payload's inner domain/message keep EIP-712 camelCase — chainId,
273
+ # verifyingContract, validAfter, validBefore — which this reads directly.)
274
+ #
275
+ # resp = client.payments.create(
276
+ # chain_id: 84532, mode: "authorize",
277
+ # amount: "100.00", token: "0x...", payer: "0x...", payee: "0x..."
278
+ # )
279
+ # sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, resp[:signing_payload])
280
+ # client.payments.sign(resp[:rail0_id], { signature: sig.to_hex })
281
+ #
282
+ # @param private_key [String] Payer's private key (0x-prefixed hex).
283
+ # @param signing_payload [Hash] The signingPayload hash from the create response.
284
+ # @return [Eip3009Signature]
285
+ def self.sign_payload(private_key, signing_payload)
286
+ payload = symbolize(signing_payload)
287
+ d = symbolize(payload[:domain])
288
+ m = symbolize(payload[:message])
289
+
290
+ domain = TokenDomain.new(
291
+ name: d[:name],
292
+ version: d[:version],
293
+ chain_id: d[:chainId],
294
+ verifying_contract: d[:verifyingContract]
295
+ )
296
+
297
+ th = typehash_for(payload[:primaryType])
298
+
299
+ do_sign(
300
+ private_key, domain,
301
+ from: m[:from],
302
+ to: m[:to],
303
+ value: m[:value].to_i,
304
+ valid_after: m[:validAfter].to_i,
305
+ valid_before: m[:validBefore].to_i,
306
+ nonce: m[:nonce],
307
+ typehash: th
308
+ )
309
+ end
310
+
311
+ # Sign a raw EIP-3009 transferWithAuthorization message.
312
+ #
313
+ # For RAIL0 payment flows prefer {sign_payload} which reads all fields from the
314
+ # API-returned signingPayload. Use this method only when you need full control over
315
+ # the message fields (e.g. integrating with a contract directly).
316
+ #
317
+ # @param private_key [String] Payer's private key (0x-prefixed or raw hex).
318
+ # @param domain [TokenDomain]
319
+ # @param params [SignTransferParams]
320
+ # @return [Eip3009Signature]
321
+ def self.sign_transfer_with_authorization(private_key, domain, params)
322
+ do_sign(
323
+ private_key, domain,
324
+ from: params.from,
325
+ to: params.to,
326
+ value: params.value,
327
+ valid_after: params.valid_after,
328
+ valid_before: params.valid_before,
329
+ nonce: params.nonce,
330
+ # Explicit, not defaulted: this method is BY NAME the transferWithAuthorization
331
+ # one. Removing the default from do_sign means no path can inherit a typehash
332
+ # it never asked for.
333
+ typehash: TRANSFER_TYPEHASH
334
+ )
335
+ end
336
+
337
+ # REMOVED — see {sign_payload}.
338
+ #
339
+ # These two rebuilt the EIP-3009 digest from a payment record: they read
340
+ # from/value/validBefore off the payment, hardcoded `validAfter = 0` and `to =
341
+ # the RAIL0 contract`, and took do_sign's default (Transfer) typehash. That
342
+ # re-imports into the client the contract versioning the gateway exists to
343
+ # absorb — only the gateway knows which contract version a given payment lives
344
+ # on, and the version selects the typehash, the domain AND the field layout.
345
+ #
346
+ # The cost is invisible until it is expensive: rail0#58 moves authorize/charge
347
+ # to ReceiveWithAuthorization, at which point {sign_payload} follows the gateway
348
+ # by construction while these two would keep signing the old typehash — a valid
349
+ # signature over a digest the token refuses, reported as success, failing
350
+ # on-chain after gas.
351
+ #
352
+ # They raise rather than being deleted outright so the migration path arrives
353
+ # with the failure instead of a bare NoMethodError. (#7)
354
+ REMOVED_MESSAGE =
355
+ "Rail0::Signing.%s was removed: it rebuilt the EIP-3009 digest from a payment " \
356
+ "record, which signs the WRONG digest as soon as the contract's payload changes " \
357
+ "(rail0#58 moves authorize/charge to ReceiveWithAuthorization). Use " \
358
+ "Rail0::Signing.sign_payload(private_key, create_response[:signing_payload]) — " \
359
+ "the gateway builds the payload, clients sign it verbatim."
360
+ private_constant :REMOVED_MESSAGE
361
+
362
+ def self.sign_authorize(_params = nil)
363
+ raise NotImplementedError, format(REMOVED_MESSAGE, "sign_authorize")
364
+ end
365
+
366
+ def self.sign_charge(_params = nil)
367
+ raise NotImplementedError, format(REMOVED_MESSAGE, "sign_charge")
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rail0
4
+ # Stablecoin addresses and capabilities for supported EVM networks.
5
+ #
6
+ # eip3009 — transferWithAuthorization (Circle / USDC standard). Required by RAIL0.
7
+ # eip2612 — permit (ERC-20 extension).
8
+ # bridged — bridge-wrapped variant that may not support either extension.
9
+ module Stablecoins
10
+ # Static metadata for a single stablecoin on a specific chain.
11
+ StablecoinInfo = Struct.new(:address, :decimals, :eip3009, :eip2612, :bridged, keyword_init: true)
12
+
13
+ # A chain's ID plus its token registry.
14
+ ChainStablecoins = Struct.new(:chain_id, :tokens, keyword_init: true)
15
+
16
+ # Token returned by {eip3009_tokens} and {eip2612_tokens}.
17
+ StablecoinToken = Struct.new(:symbol, :address, :decimals, keyword_init: true)
18
+
19
+ # Registry of known stablecoin addresses and capabilities across supported EVM chains.
20
+ REGISTRY = {
21
+ "ethereum" => ChainStablecoins.new(
22
+ chain_id: 1,
23
+ tokens: {
24
+ "USDC" => StablecoinInfo.new(address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6, eip3009: true, eip2612: false),
25
+ "EURC" => StablecoinInfo.new(address: "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c", decimals: 6, eip3009: true, eip2612: false),
26
+ "PYUSD" => StablecoinInfo.new(address: "0x6c3ea9036406852006290770BEdFcAbA0e23A0e8", decimals: 6, eip3009: true, eip2612: false),
27
+ "USDT" => StablecoinInfo.new(address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6, eip3009: false, eip2612: false),
28
+ "DAI" => StablecoinInfo.new(address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", decimals: 18, eip3009: false, eip2612: true)
29
+ }
30
+ ),
31
+ "base" => ChainStablecoins.new(
32
+ chain_id: 8453,
33
+ tokens: {
34
+ "USDC" => StablecoinInfo.new(address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, eip3009: true, eip2612: false),
35
+ "EURC" => StablecoinInfo.new(address: "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", decimals: 6, eip3009: true, eip2612: false),
36
+ "USDbC" => StablecoinInfo.new(address: "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA", decimals: 6, eip3009: false, eip2612: false, bridged: true)
37
+ }
38
+ ),
39
+ "polygon" => ChainStablecoins.new(
40
+ chain_id: 137,
41
+ tokens: {
42
+ "USDC" => StablecoinInfo.new(address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6, eip3009: true, eip2612: false),
43
+ "USDC.e" => StablecoinInfo.new(address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", decimals: 6, eip3009: true, eip2612: false, bridged: true),
44
+ "USDT" => StablecoinInfo.new(address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", decimals: 6, eip3009: false, eip2612: false),
45
+ "DAI" => StablecoinInfo.new(address: "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", decimals: 18, eip3009: false, eip2612: false)
46
+ }
47
+ ),
48
+ "arbitrumOne" => ChainStablecoins.new(
49
+ chain_id: 42161,
50
+ tokens: {
51
+ "USDC" => StablecoinInfo.new(address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, eip3009: true, eip2612: false),
52
+ "USDC.e" => StablecoinInfo.new(address: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", decimals: 6, eip3009: false, eip2612: false, bridged: true),
53
+ "USDT" => StablecoinInfo.new(address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", decimals: 6, eip3009: false, eip2612: false),
54
+ "DAI" => StablecoinInfo.new(address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", decimals: 18, eip3009: false, eip2612: true)
55
+ }
56
+ ),
57
+ "optimism" => ChainStablecoins.new(
58
+ chain_id: 10,
59
+ tokens: {
60
+ "USDC" => StablecoinInfo.new(address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", decimals: 6, eip3009: true, eip2612: false),
61
+ "USDC.e" => StablecoinInfo.new(address: "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", decimals: 6, eip3009: false, eip2612: false, bridged: true),
62
+ "USDT" => StablecoinInfo.new(address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", decimals: 6, eip3009: false, eip2612: false),
63
+ "DAI" => StablecoinInfo.new(address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", decimals: 18, eip3009: false, eip2612: true)
64
+ }
65
+ ),
66
+ "avalanche" => ChainStablecoins.new(
67
+ chain_id: 43114,
68
+ tokens: {
69
+ "USDC" => StablecoinInfo.new(address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6, eip3009: true, eip2612: false),
70
+ "USDC.e" => StablecoinInfo.new(address: "0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664", decimals: 6, eip3009: false, eip2612: false, bridged: true),
71
+ "USDT" => StablecoinInfo.new(address: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", decimals: 6, eip3009: false, eip2612: false)
72
+ }
73
+ ),
74
+ "celo" => ChainStablecoins.new(
75
+ chain_id: 42220,
76
+ tokens: {
77
+ "USDC" => StablecoinInfo.new(address: "0xcebA9300f2b948710d2De3250b7Ad3e4aFb0e50a", decimals: 6, eip3009: true, eip2612: false),
78
+ "cUSD" => StablecoinInfo.new(address: "0x765DE816845861e75A25fCA122bb6898B8B1282a", decimals: 18, eip3009: true, eip2612: false),
79
+ "cEUR" => StablecoinInfo.new(address: "0xD8763CBa276a3738E6DE85b4b3bF5FDed6D6cA73", decimals: 18, eip3009: true, eip2612: false)
80
+ }
81
+ )
82
+ }.freeze
83
+
84
+ # Returns the registry entry for a chain, or +nil+ if the chain is unknown.
85
+ #
86
+ # Supported chain names: "ethereum", "base", "polygon", "arbitrumOne",
87
+ # "optimism", "avalanche", "celo".
88
+ #
89
+ # @param chain [String]
90
+ # @return [ChainStablecoins, nil]
91
+ def self.chain_info(chain)
92
+ REGISTRY[chain]
93
+ end
94
+
95
+ # Returns all tokens on a chain that support EIP-3009 (transferWithAuthorization).
96
+ # These are the tokens compatible with RAIL0.
97
+ #
98
+ # @param chain [String]
99
+ # @return [Array<StablecoinToken>]
100
+ def self.eip3009_tokens(chain)
101
+ tokens_supporting(chain, :eip3009)
102
+ end
103
+
104
+ # Returns all tokens on a chain that support EIP-2612 (permit).
105
+ #
106
+ # @param chain [String]
107
+ # @return [Array<StablecoinToken>]
108
+ def self.eip2612_tokens(chain)
109
+ tokens_supporting(chain, :eip2612)
110
+ end
111
+
112
+ def self.tokens_supporting(chain, capability)
113
+ c = REGISTRY[chain] or return []
114
+ c.tokens.each_with_object([]) do |(symbol, info), arr|
115
+ if info.public_send(capability)
116
+ arr << StablecoinToken.new(symbol: symbol, address: info.address, decimals: info.decimals)
117
+ end
118
+ end
119
+ end
120
+
121
+ private_class_method :tokens_supporting
122
+ end
123
+ end