siwe-rb 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,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "error"
7
+ require_relative "error_type"
8
+ require_relative "parser"
9
+ require_relative "response"
10
+ require_relative "util"
11
+
12
+ module Siwe
13
+ # An EIP-4361 Sign-In with Ethereum message.
14
+ # Construct via `Siwe::Message.new(**fields)` or parse via `Siwe::Message.parse(string)`.
15
+ # Render via `#prepare_message` (alias `#to_eip4361`, `#to_s`).
16
+ class Message
17
+ FIELDS = %i[
18
+ scheme domain address statement uri version chain_id nonce
19
+ issued_at expiration_time not_before request_id resources
20
+ ].freeze
21
+
22
+ attr_reader(*FIELDS, :warnings)
23
+
24
+ def self.parse(str)
25
+ result = Parser.parse(str)
26
+ msg = allocate
27
+ msg.send(:init_from_parser, result)
28
+ msg
29
+ end
30
+
31
+ def self.from_json(json_str)
32
+ data = JSON.parse(json_str, symbolize_names: true)
33
+ new(**data.slice(*FIELDS))
34
+ rescue JSON::ParserError => e
35
+ raise Error.new(ErrorType::UNABLE_TO_PARSE, message: "Invalid JSON: #{e.message}")
36
+ end
37
+
38
+ def initialize(domain:, address:, uri:, chain_id:, nonce: nil, version: "1",
39
+ scheme: nil, statement: nil, issued_at: nil,
40
+ expiration_time: nil, not_before: nil, request_id: nil, resources: nil)
41
+ @warnings = []
42
+ @scheme = scheme
43
+ @domain = domain
44
+ @address = normalize_address(address)
45
+ @statement = statement
46
+ @uri = uri
47
+ @version = version
48
+ @chain_id = coerce_chain_id(chain_id)
49
+ @nonce = nonce || Util.generate_nonce
50
+ @issued_at = issued_at || Time.now.utc.iso8601
51
+ @expiration_time = expiration_time
52
+ @not_before = not_before
53
+ @request_id = request_id
54
+ @resources = resources
55
+
56
+ validate_required!
57
+ roundtrip_validate!
58
+ freeze
59
+ end
60
+
61
+ def prepare_message
62
+ header_prefix = @scheme ? "#{@scheme}://#{@domain}" : @domain
63
+ header = "#{header_prefix} wants you to sign in with your Ethereum account:"
64
+
65
+ prefix = "#{header}\n#{@address}"
66
+ prefix = if @statement.nil?
67
+ "#{prefix}\n\n"
68
+ else
69
+ "#{prefix}\n\n#{@statement}\n"
70
+ end
71
+
72
+ suffix = "URI: #{@uri}\nVersion: #{@version}\nChain ID: #{@chain_id}"
73
+ suffix << "\nNonce: #{@nonce}"
74
+ suffix << "\nIssued At: #{@issued_at}" if @issued_at
75
+ suffix << "\nExpiration Time: #{@expiration_time}" if @expiration_time
76
+ suffix << "\nNot Before: #{@not_before}" if @not_before
77
+ suffix << "\nRequest ID: #{@request_id}" unless @request_id.nil?
78
+ if @resources
79
+ suffix << "\nResources:"
80
+ @resources.each { |r| suffix << "\n- #{r}" }
81
+ end
82
+
83
+ "#{prefix}\n#{suffix}"
84
+ end
85
+
86
+ alias to_eip4361 prepare_message
87
+ alias to_s prepare_message
88
+
89
+ # Verify a signature against this message and the verification params.
90
+ # Returns Siwe::Response — never raises on verification failure.
91
+ def verify(signature:, domain:, nonce:, scheme: nil, uri: nil, chain_id: nil,
92
+ request_id: nil, time: nil, config: nil, strict: false)
93
+ cfg = config || Siwe.config
94
+
95
+ check_param_mismatches!(domain: domain, nonce: nonce, scheme: scheme,
96
+ uri: uri, chain_id: chain_id, request_id: request_id, strict: strict)
97
+ check_temporal!(time)
98
+ check_signature!(signature, cfg)
99
+
100
+ Response.new(success: true, error: nil, data: self)
101
+ rescue Error => e
102
+ Response.new(success: false, error: e, data: self)
103
+ end
104
+
105
+ # Verify a signature; raises Siwe::Error on failure, returns self on success.
106
+ def verify!(**)
107
+ response = verify(**)
108
+ raise response.error if response.failure?
109
+
110
+ self
111
+ end
112
+
113
+ def to_h
114
+ FIELDS.to_h { |f| [f, instance_variable_get("@#{f}")] }
115
+ end
116
+
117
+ def to_json(*)
118
+ to_h.to_json(*)
119
+ end
120
+
121
+ def ==(other)
122
+ other.is_a?(Message) && to_h == other.to_h
123
+ end
124
+ alias eql? ==
125
+
126
+ def hash
127
+ to_h.hash
128
+ end
129
+
130
+ private
131
+
132
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
133
+ def check_param_mismatches!(domain:, nonce:, scheme:, uri:, chain_id:, request_id:, strict:)
134
+ raise Error.new(ErrorType::MISSING_DOMAIN) if domain.nil? || domain.empty?
135
+ raise Error.new(ErrorType::MISSING_NONCE) if nonce.nil? || nonce.empty?
136
+
137
+ raise Error.new(ErrorType::DOMAIN_MISMATCH, expected: @domain, received: domain) if @domain != domain
138
+ if scheme && @scheme != scheme
139
+ raise Error.new(ErrorType::SCHEME_MISMATCH, expected: @scheme.to_s, received: scheme)
140
+ end
141
+ raise Error.new(ErrorType::NONCE_MISMATCH, expected: @nonce, received: nonce) if @nonce != nonce
142
+
143
+ raise Error.new(ErrorType::MISSING_URI) if strict && uri.nil?
144
+ raise Error.new(ErrorType::URI_MISMATCH, expected: @uri, received: uri) if uri && @uri != uri
145
+
146
+ raise Error.new(ErrorType::MISSING_CHAIN_ID) if strict && chain_id.nil?
147
+ if chain_id && @chain_id != chain_id
148
+ raise Error.new(ErrorType::CHAIN_ID_MISMATCH, expected: @chain_id.to_s, received: chain_id.to_s)
149
+ end
150
+
151
+ return unless request_id && @request_id != request_id
152
+
153
+ raise Error.new(ErrorType::REQUEST_ID_MISMATCH, expected: @request_id.to_s, received: request_id.to_s)
154
+ end
155
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
156
+
157
+ def check_temporal!(time_str)
158
+ check_at = time_str ? Time.iso8601(time_str) : Time.now.utc
159
+ if @expiration_time && Time.iso8601(@expiration_time) < check_at
160
+ raise Error.new(ErrorType::EXPIRED_MESSAGE, expected: @expiration_time, received: check_at.iso8601)
161
+ end
162
+ return unless @not_before && Time.iso8601(@not_before) > check_at
163
+
164
+ raise Error.new(ErrorType::NOT_YET_VALID_MESSAGE, expected: @not_before, received: check_at.iso8601)
165
+ rescue ArgumentError => e
166
+ raise Error.new(ErrorType::INVALID_TIME_FORMAT, message: e.message)
167
+ end
168
+
169
+ def check_signature!(signature, cfg)
170
+ if signature.nil? || signature.empty?
171
+ raise Error.new(ErrorType::INVALID_SIGNATURE, expected: "non-empty signature", received: signature.inspect)
172
+ end
173
+
174
+ recovered = recover_eoa(signature, cfg)
175
+ return if recovered && recovered.downcase == @address.downcase
176
+
177
+ # EOA recovery failed or address mismatch — try smart-wallet path if configured.
178
+ return if smart_wallet_valid?(signature, cfg)
179
+
180
+ raise Error.new(ErrorType::INVALID_SIGNATURE, expected: @address, received: recovered.to_s)
181
+ end
182
+
183
+ def recover_eoa(signature, cfg)
184
+ cfg.adapter.verify_message(prepare_message, signature)
185
+ rescue StandardError
186
+ nil
187
+ end
188
+
189
+ def smart_wallet_valid?(signature, cfg)
190
+ rpc = resolve_rpc(cfg)
191
+ return false if rpc.nil?
192
+
193
+ check_chain_id_match!(rpc)
194
+ SmartWallet.verify(rpc: rpc, address: @address, message: prepare_message, signature: signature)
195
+ end
196
+
197
+ def resolve_rpc(cfg)
198
+ return cfg.rpc if cfg.rpc
199
+ return Rpc::HttpClient.new(cfg.rpc_url) if cfg.rpc_url
200
+
201
+ nil
202
+ end
203
+
204
+ def check_chain_id_match!(rpc)
205
+ return unless rpc.respond_to?(:chain_id)
206
+
207
+ rpc_chain = rpc.chain_id
208
+ return if rpc_chain.nil? || rpc_chain == @chain_id
209
+
210
+ raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
211
+ expected: @chain_id.to_s, received: rpc_chain.to_s)
212
+ end
213
+
214
+ def init_from_parser(result)
215
+ result[:fields].each { |k, v| instance_variable_set("@#{k}", v) }
216
+ @warnings = result[:warnings]
217
+ freeze
218
+ end
219
+
220
+ def normalize_address(addr)
221
+ raise Error.new(ErrorType::INVALID_ADDRESS, expected: "valid EIP-55 address", received: addr.to_s) if addr.nil?
222
+
223
+ case Util.address_case(addr)
224
+ when :checksum
225
+ addr
226
+ when :lower, :upper
227
+ @warnings << "address is not EIP-55 checksummed - #{addr}"
228
+ Util.checksum_address(addr) || addr
229
+ when :invalid_checksum
230
+ raise Error.new(ErrorType::INVALID_ADDRESS, expected: "valid EIP-55 address", received: addr)
231
+ else
232
+ raise Error.new(ErrorType::INVALID_ADDRESS, expected: "0x + 40 hex chars", received: addr.to_s)
233
+ end
234
+ end
235
+
236
+ def coerce_chain_id(value)
237
+ case value
238
+ when Integer then value
239
+ when String
240
+ unless value.match?(/\A\d+\z/)
241
+ raise Error.new(ErrorType::UNABLE_TO_PARSE, expected: "integer chain ID", received: value)
242
+ end
243
+
244
+ value.to_i
245
+ else
246
+ raise Error.new(ErrorType::UNABLE_TO_PARSE, expected: "integer chain ID", received: value.inspect)
247
+ end
248
+ end
249
+
250
+ def validate_required!
251
+ if @domain.nil? || @domain.to_s.empty?
252
+ raise Error.new(ErrorType::INVALID_DOMAIN, expected: "non-empty domain",
253
+ received: @domain.inspect)
254
+ end
255
+ if @uri.nil? || @uri.to_s.empty?
256
+ raise Error.new(ErrorType::INVALID_URI, expected: "non-empty URI",
257
+ received: @uri.inspect)
258
+ end
259
+ unless @version == "1"
260
+ raise Error.new(ErrorType::INVALID_MESSAGE_VERSION, expected: "1",
261
+ received: @version.to_s)
262
+ end
263
+ return if Parser::NONCE_REGEX.match?(@nonce.to_s)
264
+
265
+ raise Error.new(ErrorType::INVALID_NONCE, expected: "alphanumeric 8+ chars",
266
+ received: @nonce.to_s)
267
+ end
268
+
269
+ def roundtrip_validate!
270
+ Parser.parse(prepare_message)
271
+ rescue Error => e
272
+ raise Error.new(e.type, expected: e.expected, received: e.received,
273
+ message: "Constructed message fails to parse: #{e.message}")
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "error_type"
5
+ require_relative "util"
6
+
7
+ module Siwe
8
+ # Parser for the EIP-4361 Sign-In with Ethereum message format.
9
+ # Validates structure line-by-line and per-field via ABNF-aligned regexes.
10
+ # Returns a hash of typed fields plus an array of non-fatal warnings.
11
+ class Parser
12
+ HEADER_SUFFIX = " wants you to sign in with your Ethereum account:"
13
+ SCHEME_REGEX = /\A([A-Za-z][A-Za-z0-9+\-.]*)\z/
14
+ # RFC 3986 authority (without scheme://) — userinfo, host (reg-name/IPv4/IP-literal), port.
15
+ DOMAIN_REGEX = /\A[A-Za-z0-9\-._~%!$&'()*+,;=:@\[\]]+\z/
16
+ ADDRESS_REGEX = /\A0x[0-9a-fA-F]{40}\z/
17
+ NONCE_REGEX = /\A[A-Za-z0-9]{8,}\z/
18
+ CHAIN_REGEX = /\A[0-9]+\z/
19
+ # Statement chars per ABNF: %d32-33 %d35-36 %d38-59 %d61 %d63-64 %d91 %d93 %d95 %d126.
20
+ # Note: explicitly excludes %d34 (") and %d37 (%). Built via Regexp.new to avoid
21
+ # accidental string interpolation of `#$&` inside a regex literal.
22
+ STATEMENT_REGEX = Regexp.new('\A[a-zA-Z0-9 !\x23\x24\x26-\x3B\x3D\x3F\x40\x5B\x5D\x5F\x7E]*\z')
23
+ REQUEST_ID_REGEX = %r{\A[A-Za-z0-9\-._~%!$&'()*+,;=:@/?]*\z}
24
+ URI_REGEX = /\A[A-Za-z][A-Za-z0-9+\-.]*:[^\s]*\z/
25
+
26
+ def self.parse(str)
27
+ new(str).parse
28
+ end
29
+
30
+ def initialize(str)
31
+ @str = str
32
+ @warnings = []
33
+ end
34
+
35
+ def parse # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
36
+ raise unable_to_parse("message is not a string") unless @str.is_a?(String)
37
+
38
+ lines = @str.split("\n", -1)
39
+ raise unable_to_parse("message too short") if lines.length < 6
40
+
41
+ scheme, domain = parse_header(lines[0])
42
+ address = parse_address(lines[1])
43
+
44
+ uri_idx = lines.index { |l| l.start_with?("URI: ") }
45
+ raise unable_to_parse("URI: line not found") if uri_idx.nil? || uri_idx < 3
46
+
47
+ statement = parse_statement_block(lines[2...uri_idx])
48
+
49
+ uri = parse_field(lines[uri_idx], "URI: ", :invalid_uri, URI_REGEX)
50
+ version = parse_field(lines[uri_idx + 1], "Version: ", :invalid_message_version, /\A1\z/)
51
+ chain_str = parse_field(lines[uri_idx + 2], "Chain ID: ", :unable_to_parse, CHAIN_REGEX)
52
+ nonce = parse_field(lines[uri_idx + 3], "Nonce: ", :invalid_nonce, NONCE_REGEX)
53
+ issued_at = parse_iso_field(lines[uri_idx + 4], "Issued At: ", "issuedAt")
54
+
55
+ cursor = uri_idx + 5
56
+ expiration_time = nil
57
+ not_before = nil
58
+ request_id = nil
59
+ resources = nil
60
+
61
+ if cursor < lines.length && lines[cursor]&.start_with?("Expiration Time: ")
62
+ expiration_time = parse_iso_field(lines[cursor], "Expiration Time: ", "expirationTime")
63
+ cursor += 1
64
+ end
65
+ if cursor < lines.length && lines[cursor]&.start_with?("Not Before: ")
66
+ not_before = parse_iso_field(lines[cursor], "Not Before: ", "notBefore")
67
+ cursor += 1
68
+ end
69
+ if cursor < lines.length && lines[cursor]&.start_with?("Request ID: ")
70
+ request_id = parse_field(lines[cursor], "Request ID: ", :unable_to_parse, REQUEST_ID_REGEX)
71
+ cursor += 1
72
+ end
73
+ if cursor < lines.length && lines[cursor] == "Resources:"
74
+ cursor += 1
75
+ resources = parse_resources(lines, cursor)
76
+ cursor += resources.length
77
+ end
78
+
79
+ validate_trailing(lines, cursor)
80
+ classify_address!(address)
81
+
82
+ {
83
+ fields: {
84
+ scheme: scheme,
85
+ domain: domain,
86
+ address: address,
87
+ statement: statement,
88
+ uri: uri,
89
+ version: version,
90
+ chain_id: chain_str.to_i,
91
+ nonce: nonce,
92
+ issued_at: issued_at,
93
+ expiration_time: expiration_time,
94
+ not_before: not_before,
95
+ request_id: request_id,
96
+ resources: resources
97
+ },
98
+ warnings: @warnings
99
+ }
100
+ end
101
+
102
+ private
103
+
104
+ def parse_header(line) # rubocop:disable Metrics/AbcSize
105
+ raise unable_to_parse("missing header") if line.nil? || line.empty?
106
+ raise unable_to_parse("malformed header") unless line.end_with?(HEADER_SUFFIX)
107
+
108
+ prefix = line[0...(line.length - HEADER_SUFFIX.length)]
109
+ raise unable_to_parse("empty domain") if prefix.empty?
110
+
111
+ if prefix.include?("://")
112
+ scheme, _, domain = prefix.partition("://")
113
+ raise unable_to_parse("invalid scheme: #{scheme.inspect}") unless SCHEME_REGEX.match?(scheme)
114
+ raise unable_to_parse("empty domain") if domain.empty?
115
+ unless DOMAIN_REGEX.match?(domain)
116
+ raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
117
+ received: domain)
118
+ end
119
+
120
+ [scheme, domain]
121
+ else
122
+ unless DOMAIN_REGEX.match?(prefix)
123
+ raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
124
+ received: prefix)
125
+ end
126
+
127
+ [nil, prefix]
128
+ end
129
+ end
130
+
131
+ def parse_address(line)
132
+ raise unable_to_parse("missing address line") if line.nil?
133
+ unless ADDRESS_REGEX.match?(line)
134
+ raise Error.new(ErrorType::INVALID_ADDRESS, expected: "0x + 40 hex chars",
135
+ received: line)
136
+ end
137
+
138
+ line
139
+ end
140
+
141
+ def classify_address!(address)
142
+ case Util.address_case(address)
143
+ when :checksum, :lower, :upper
144
+ @warnings << "address is not EIP-55 checksummed - #{address}" if Util.address_case(address) != :checksum
145
+ when :invalid_checksum
146
+ raise Error.new(ErrorType::INVALID_ADDRESS, expected: "EIP-55 checksum address",
147
+ received: address)
148
+ else
149
+ raise Error.new(ErrorType::INVALID_ADDRESS, expected: "0x + 40 hex chars",
150
+ received: address)
151
+ end
152
+ end
153
+
154
+ def parse_statement_block(stmt_lines)
155
+ case stmt_lines.length
156
+ when 2
157
+ raise unable_to_parse("malformed statement block") unless stmt_lines.all?(&:empty?)
158
+
159
+ nil
160
+ when 3
161
+ first, stmt, last = stmt_lines
162
+ raise unable_to_parse("statement must be enclosed by blank lines") unless first.empty? && last.empty?
163
+ raise unable_to_parse("statement must not contain LF") if stmt.include?("\n")
164
+ raise unable_to_parse("statement contains disallowed characters") unless STATEMENT_REGEX.match?(stmt)
165
+
166
+ stmt
167
+ else
168
+ raise unable_to_parse("malformed statement block (#{stmt_lines.length} lines)")
169
+ end
170
+ end
171
+
172
+ def parse_field(line, prefix, error_type, regex)
173
+ raise unable_to_parse("missing #{prefix.strip}") if line.nil?
174
+ raise unable_to_parse("expected #{prefix.strip} line") unless line.start_with?(prefix)
175
+
176
+ value = line[prefix.length..]
177
+ raise Error.new(error_type, expected: prefix.strip, received: value) unless regex.match?(value)
178
+
179
+ value
180
+ end
181
+
182
+ def parse_iso_field(line, prefix, name)
183
+ value = parse_field(line, prefix, :invalid_time_format, /.+/)
184
+ unless Util.valid_iso8601?(value)
185
+ raise Error.new(ErrorType::INVALID_TIME_FORMAT, expected: "ISO 8601 #{name}", received: value)
186
+ end
187
+
188
+ value
189
+ end
190
+
191
+ def parse_resources(lines, start_idx)
192
+ idx = start_idx
193
+ out = []
194
+ while idx < lines.length && lines[idx].start_with?("- ")
195
+ uri = lines[idx][2..]
196
+ unless URI_REGEX.match?(uri)
197
+ raise Error.new(ErrorType::INVALID_URI, expected: "RFC 3986 resource URI", received: uri)
198
+ end
199
+
200
+ out << uri
201
+ idx += 1
202
+ end
203
+ out
204
+ end
205
+
206
+ def validate_trailing(lines, cursor)
207
+ while cursor < lines.length
208
+ unless lines[cursor].empty?
209
+ raise unable_to_parse("trailing content at line #{cursor + 1}: #{lines[cursor].inspect}")
210
+ end
211
+
212
+ cursor += 1
213
+ end
214
+ end
215
+
216
+ def unable_to_parse(detail)
217
+ Error.new(ErrorType::UNABLE_TO_PARSE, message: "Unable to parse the message: #{detail}")
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siwe
4
+ Response = Data.define(:success, :error, :data) do
5
+ def success?
6
+ success == true
7
+ end
8
+
9
+ def failure?
10
+ !success?
11
+ end
12
+ end
13
+ end
data/lib/siwe/rpc.rb ADDED
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "error"
8
+ require_relative "error_type"
9
+
10
+ module Siwe
11
+ module Rpc
12
+ # Minimal JSON-RPC client for eth_call. Default implementation used when
13
+ # Siwe.configure { |c| c.rpc_url = ... } is set. Anything that responds to
14
+ # `eth_call(to:, data:, block:)` (and optionally `chain_id`) can be plugged
15
+ # in via `c.rpc = ...` to use a different transport (web3.rb, eth-rpc, etc.).
16
+ class HttpClient
17
+ attr_reader :url, :timeout
18
+
19
+ def initialize(url, chain_id: nil, timeout: 10)
20
+ raise ArgumentError, "rpc url is required" if url.nil? || url.empty?
21
+
22
+ @url = url
23
+ @uri = URI(url)
24
+ @chain_id = chain_id
25
+ @timeout = timeout
26
+ end
27
+
28
+ def chain_id
29
+ @chain_id ||= fetch_chain_id
30
+ end
31
+
32
+ # Send eth_call. Returns the result as a hex string WITHOUT the 0x prefix.
33
+ # When +to+ is nil, omits it from params (used by EIP-6492 deploy-and-call).
34
+ # Raises Siwe::Error :rpc_error on transport / HTTP / JSON-RPC failure.
35
+ def eth_call(to:, data:, block: "latest")
36
+ call_params = { data: data }
37
+ call_params[:to] = to if to
38
+ rpc_request("eth_call", [call_params, block])
39
+ end
40
+
41
+ private
42
+
43
+ def fetch_chain_id
44
+ hex = rpc_request("eth_chainId", [])
45
+ hex.to_i(16)
46
+ rescue Error
47
+ nil
48
+ end
49
+
50
+ def rpc_request(method, params)
51
+ body = { jsonrpc: "2.0", id: 1, method: method, params: params }.to_json
52
+ response = post(body)
53
+ parse_response(response)
54
+ end
55
+
56
+ def post(body)
57
+ http = Net::HTTP.new(@uri.host, @uri.port)
58
+ http.use_ssl = (@uri.scheme == "https")
59
+ http.open_timeout = @timeout
60
+ http.read_timeout = @timeout
61
+
62
+ path = @uri.request_uri
63
+ path = "/" if path.empty?
64
+ request = Net::HTTP::Post.new(path, "Content-Type" => "application/json")
65
+ request.body = body
66
+ http.request(request)
67
+ rescue StandardError => e
68
+ raise Error.new(ErrorType::RPC_ERROR, message: "RPC transport error: #{e.class}: #{e.message}")
69
+ end
70
+
71
+ def parse_response(response)
72
+ unless response.is_a?(Net::HTTPSuccess)
73
+ raise Error.new(ErrorType::RPC_ERROR, message: "RPC HTTP #{response.code}: #{response.message}")
74
+ end
75
+
76
+ body = JSON.parse(response.body)
77
+ raise Error.new(ErrorType::RPC_ERROR, message: "RPC error: #{body["error"].inspect}") if body["error"]
78
+
79
+ result = body["result"]
80
+ raise Error.new(ErrorType::RPC_ERROR, message: "RPC returned no result") if result.nil?
81
+
82
+ strip_hex_prefix(result.to_s)
83
+ rescue JSON::ParserError => e
84
+ raise Error.new(ErrorType::RPC_ERROR, message: "Invalid RPC JSON: #{e.message}")
85
+ end
86
+
87
+ def strip_hex_prefix(hex)
88
+ hex.start_with?("0x") ? hex[2..] : hex
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "eth"
4
+
5
+ require_relative "eip6492"
6
+ require_relative "error"
7
+ require_relative "error_type"
8
+
9
+ module Siwe
10
+ # Smart-wallet signature verification via the EIP-6492 off-chain universal
11
+ # validator. A single eth_call (deploy-and-call) covers both deployed ERC-1271
12
+ # wallets (e.g. Safe) and counterfactual EIP-6492-wrapped signatures
13
+ # (e.g. Coinbase Smart Wallet).
14
+ module SmartWallet
15
+ module_function
16
+
17
+ # Verify a smart-wallet signature.
18
+ # rpc — anything responding to `eth_call(to:, data:, block:)`
19
+ # address — claimed signer (smart contract or factory-deployed address)
20
+ # message — EIP-4361 message string (will be EIP-191 hashed internally)
21
+ # signature — hex-encoded signature, may or may not include 0x prefix,
22
+ # may include the EIP-6492 magic-suffix wrapper
23
+ # Returns true (valid) or false (validator returned non-1).
24
+ # Re-raises Siwe::Error from the rpc client on transport errors.
25
+ def verify(rpc:, address:, message:, signature:) # rubocop:disable Naming/PredicateMethod
26
+ hash_hex = bin_to_hex(eip191_hash(message))
27
+ sig_hex = strip_hex_prefix(signature)
28
+
29
+ args = Eth::Abi.encode(
30
+ %w[address bytes32 bytes],
31
+ [address, Eth::Util.hex_to_bin(hash_hex), Eth::Util.hex_to_bin(sig_hex)]
32
+ )
33
+ data = "0x#{Eip6492::VALIDATOR_BYTECODE}#{Eth::Util.bin_to_hex(args)}"
34
+
35
+ result = rpc.eth_call(to: nil, data: data)
36
+ result_indicates_valid?(result)
37
+ end
38
+
39
+ # Hash a message per EIP-191 (`personal_sign`). Returns binary keccak256.
40
+ def eip191_hash(message)
41
+ prefixed = Eth::Signature.prefix_message(message)
42
+ Eth::Util.keccak256(prefixed)
43
+ end
44
+
45
+ # Validator returns 0x01 (possibly zero-padded to 32 bytes) for valid,
46
+ # 0x00 for invalid. The default RPC client strips the 0x prefix; for
47
+ # callers that don't, normalize here.
48
+ def result_indicates_valid?(hex)
49
+ return false if hex.nil?
50
+
51
+ hex = strip_hex_prefix(hex.to_s)
52
+ hex.gsub(/\A0+/, "") == "1"
53
+ end
54
+
55
+ def strip_hex_prefix(hex)
56
+ hex.start_with?("0x") ? hex[2..] : hex
57
+ end
58
+
59
+ def bin_to_hex(bin)
60
+ bin.unpack1("H*")
61
+ end
62
+ end
63
+ end