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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +753 -0
- data/lib/rail0/api_error.rb +50 -0
- data/lib/rail0/backoff.rb +66 -0
- data/lib/rail0/client.rb +89 -0
- data/lib/rail0/default_logger.rb +82 -0
- data/lib/rail0/error_hints.rb +80 -0
- data/lib/rail0/http_client.rb +99 -0
- data/lib/rail0/request.rb +211 -0
- data/lib/rail0/resources/accounts.rb +35 -0
- data/lib/rail0/resources/analytics.rb +112 -0
- data/lib/rail0/resources/auth.rb +192 -0
- data/lib/rail0/resources/chains.rb +29 -0
- data/lib/rail0/resources/disputes.rb +35 -0
- data/lib/rail0/resources/health.rb +23 -0
- data/lib/rail0/resources/payment_methods.rb +37 -0
- data/lib/rail0/resources/payments.rb +344 -0
- data/lib/rail0/resources/query.rb +19 -0
- data/lib/rail0/resources/tokens.rb +28 -0
- data/lib/rail0/resources/wallets.rb +160 -0
- data/lib/rail0/resources/webhooks.rb +146 -0
- data/lib/rail0/signing.rb +370 -0
- data/lib/rail0/stablecoins.rb +123 -0
- data/lib/rail0/types.rb +295 -0
- data/lib/rail0/version.rb +9 -0
- data/lib/rail0/webhook_signature.rb +80 -0
- data/lib/rail0-sdk.rb +11 -0
- data/lib/rail0.rb +20 -0
- metadata +91 -0
|
@@ -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
|