erpc-sdk 0.7.0 → 0.8.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,1507 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "socket"
8
+ require "timeout"
9
+ require "uri"
10
+
11
+ require_relative "errors"
12
+ require_relative "config"
13
+ require_relative "transport"
14
+ require_relative "token_catalog"
15
+ require_relative "generated/bridge_capabilities"
16
+
17
+ module ERPC
18
+ # Stable machine-readable errors for the optional, standalone Mayan adapter.
19
+ module BridgeErrorCode
20
+ INVALID_ARGUMENT = "BRIDGE_INVALID_ARGUMENT"
21
+ UNSUPPORTED_ROUTE = "BRIDGE_UNSUPPORTED_ROUTE"
22
+ PROVIDER_AUTH_REQUIRED = "BRIDGE_PROVIDER_AUTH_REQUIRED"
23
+ PROVIDER_TRANSPORT = "BRIDGE_PROVIDER_TRANSPORT"
24
+ PROVIDER_HTTP = "BRIDGE_PROVIDER_HTTP"
25
+ PROVIDER_INVALID_RESPONSE = "BRIDGE_PROVIDER_INVALID_RESPONSE"
26
+ QUOTE_UNAVAILABLE = "BRIDGE_QUOTE_UNAVAILABLE"
27
+ QUOTE_EXPIRED = "BRIDGE_QUOTE_EXPIRED"
28
+ QUOTE_MISMATCH = "BRIDGE_QUOTE_MISMATCH"
29
+ BUILD_INVALID = "BRIDGE_BUILD_INVALID"
30
+ STATUS_NOT_FOUND = "BRIDGE_STATUS_NOT_FOUND"
31
+ TIMEOUT = "BRIDGE_TIMEOUT"
32
+ ABORTED = "BRIDGE_ABORTED"
33
+
34
+ BRIDGE_INVALID_ARGUMENT = INVALID_ARGUMENT
35
+ BRIDGE_UNSUPPORTED_ROUTE = UNSUPPORTED_ROUTE
36
+ BRIDGE_PROVIDER_AUTH_REQUIRED = PROVIDER_AUTH_REQUIRED
37
+ BRIDGE_PROVIDER_TRANSPORT = PROVIDER_TRANSPORT
38
+ BRIDGE_PROVIDER_HTTP = PROVIDER_HTTP
39
+ BRIDGE_PROVIDER_INVALID_RESPONSE = PROVIDER_INVALID_RESPONSE
40
+ BRIDGE_QUOTE_UNAVAILABLE = QUOTE_UNAVAILABLE
41
+ BRIDGE_QUOTE_EXPIRED = QUOTE_EXPIRED
42
+ BRIDGE_QUOTE_MISMATCH = QUOTE_MISMATCH
43
+ BRIDGE_BUILD_INVALID = BUILD_INVALID
44
+ BRIDGE_STATUS_NOT_FOUND = STATUS_NOT_FOUND
45
+ BRIDGE_TIMEOUT = TIMEOUT
46
+ BRIDGE_ABORTED = ABORTED
47
+
48
+ ALL = [
49
+ INVALID_ARGUMENT,
50
+ UNSUPPORTED_ROUTE,
51
+ PROVIDER_AUTH_REQUIRED,
52
+ PROVIDER_TRANSPORT,
53
+ PROVIDER_HTTP,
54
+ PROVIDER_INVALID_RESPONSE,
55
+ QUOTE_UNAVAILABLE,
56
+ QUOTE_EXPIRED,
57
+ QUOTE_MISMATCH,
58
+ BUILD_INVALID,
59
+ STATUS_NOT_FOUND,
60
+ TIMEOUT,
61
+ ABORTED
62
+ ].freeze
63
+ end
64
+
65
+ BRIDGE_ERROR_MESSAGES = {
66
+ BridgeErrorCode::INVALID_ARGUMENT => "Bridge request is invalid",
67
+ BridgeErrorCode::UNSUPPORTED_ROUTE => "Bridge route is unsupported",
68
+ BridgeErrorCode::PROVIDER_AUTH_REQUIRED => "Bridge provider authentication is required",
69
+ BridgeErrorCode::PROVIDER_TRANSPORT => "Bridge provider transport failed",
70
+ BridgeErrorCode::PROVIDER_HTTP => "Bridge provider HTTP request failed",
71
+ BridgeErrorCode::PROVIDER_INVALID_RESPONSE => "Bridge provider response is invalid",
72
+ BridgeErrorCode::QUOTE_UNAVAILABLE => "Bridge quote is unavailable",
73
+ BridgeErrorCode::QUOTE_EXPIRED => "Bridge quote is expired",
74
+ BridgeErrorCode::QUOTE_MISMATCH => "Bridge quote does not match the request",
75
+ BridgeErrorCode::BUILD_INVALID => "Bridge provider build is invalid",
76
+ BridgeErrorCode::STATUS_NOT_FOUND => "Bridge status was not found",
77
+ BridgeErrorCode::TIMEOUT => "Bridge provider request timed out",
78
+ BridgeErrorCode::ABORTED => "Bridge provider request was aborted"
79
+ }.freeze
80
+
81
+ class BridgeError < Error
82
+ attr_reader :code, :status
83
+
84
+ def initialize(code, status = nil)
85
+ @code = code.to_s.freeze
86
+ @status = status
87
+ super(BRIDGE_ERROR_MESSAGES.fetch(@code, "Bridge provider request failed"))
88
+ end
89
+ end
90
+
91
+ # Rebuild an error received at a public/provider boundary. An adapter can
92
+ # hand us a BridgeError that already carries an unsafe cause or mutable
93
+ # metadata, so re-raising that object is not sufficient.
94
+ def self.raise_safe_bridge_error(error)
95
+ code = begin
96
+ candidate = error.code
97
+ if candidate.is_a?(String) && BridgeErrorCode::ALL.include?(candidate)
98
+ candidate.dup
99
+ else
100
+ BridgeErrorCode::PROVIDER_TRANSPORT
101
+ end
102
+ rescue StandardError
103
+ BridgeErrorCode::PROVIDER_TRANSPORT
104
+ end
105
+ status = begin
106
+ candidate = error.status
107
+ candidate.is_a?(Integer) && candidate.between?(100, 599) ? candidate : nil
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ raise BridgeError.new(code, status), cause: nil
112
+ end
113
+
114
+ # Configuration for a standalone Mayan Swift v2 client.
115
+ #
116
+ # The adapter is deliberately independent from ClientConfig. In particular,
117
+ # no eRPC key, header, cookie, or route is copied into provider requests.
118
+ class MayanSwiftV2BridgeConfig
119
+ DEFAULT_BUILDER_ENDPOINT = "https://tx-builder.mayan.finance"
120
+ DEFAULT_EXPLORER_ENDPOINT = "https://explorer-api.mayan.finance/v3"
121
+ DEFAULT_TIMEOUT = 30.0
122
+
123
+ attr_reader :builder_endpoint, :explorer_endpoint, :builder_api_key,
124
+ :allow_unauthenticated_build, :minimum_quote_validity_seconds,
125
+ :timeout, :http_adapter
126
+
127
+ def initialize(builder_endpoint: DEFAULT_BUILDER_ENDPOINT,
128
+ explorer_endpoint: DEFAULT_EXPLORER_ENDPOINT,
129
+ builder_api_key: nil,
130
+ allow_unauthenticated_build: false,
131
+ minimum_quote_validity_seconds: 60,
132
+ timeout: DEFAULT_TIMEOUT,
133
+ http_adapter: nil,
134
+ **aliases)
135
+ if aliases.key?(:builderEndpoint)
136
+ builder_endpoint = aliases.delete(:builderEndpoint)
137
+ end
138
+ if aliases.key?(:explorerEndpoint)
139
+ explorer_endpoint = aliases.delete(:explorerEndpoint)
140
+ end
141
+ if aliases.key?(:builderApiKey)
142
+ builder_api_key = aliases.delete(:builderApiKey)
143
+ end
144
+ if aliases.key?(:allowUnauthenticatedBuild)
145
+ allow_unauthenticated_build = aliases.delete(:allowUnauthenticatedBuild)
146
+ end
147
+ if aliases.key?(:minimumQuoteValiditySeconds)
148
+ minimum_quote_validity_seconds = aliases.delete(:minimumQuoteValiditySeconds)
149
+ end
150
+ if aliases.key?(:timeoutSeconds)
151
+ timeout = aliases.delete(:timeoutSeconds)
152
+ end
153
+ if aliases.key?(:timeout_seconds)
154
+ timeout = aliases.delete(:timeout_seconds)
155
+ end
156
+ if aliases.key?(:timeout_ms)
157
+ timeout = aliases.delete(:timeout_ms).to_f / 1000.0
158
+ end
159
+ if aliases.key?(:timeoutMs)
160
+ timeout = aliases.delete(:timeoutMs).to_f / 1000.0
161
+ end
162
+ if aliases.key?(:httpAdapter)
163
+ http_adapter = aliases.delete(:httpAdapter)
164
+ end
165
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless aliases.empty?
166
+
167
+ @builder_endpoint = normalize_endpoint(builder_endpoint)
168
+ @explorer_endpoint = normalize_endpoint(explorer_endpoint)
169
+ @builder_api_key = normalize_api_key(builder_api_key)
170
+ unless allow_unauthenticated_build == true || allow_unauthenticated_build == false
171
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
172
+ end
173
+ unless minimum_quote_validity_seconds.is_a?(Integer) &&
174
+ minimum_quote_validity_seconds >= 0 && minimum_quote_validity_seconds <= 300
175
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
176
+ end
177
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
178
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
179
+ end
180
+ if !http_adapter.nil? && !http_adapter.respond_to?(:request)
181
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
182
+ end
183
+
184
+ @allow_unauthenticated_build = allow_unauthenticated_build
185
+ @minimum_quote_validity_seconds = minimum_quote_validity_seconds
186
+ @timeout = timeout.to_f
187
+ @http_adapter = http_adapter
188
+ freeze
189
+ end
190
+
191
+ def inspect
192
+ key = builder_api_key.nil? ? nil : "[REDACTED]"
193
+ adapter = http_adapter.nil? ? nil : "[configured]"
194
+ "#<#{self.class} builder_endpoint=#{builder_endpoint.inspect} " \
195
+ "explorer_endpoint=#{explorer_endpoint.inspect} " \
196
+ "builder_api_key=#{key.inspect} " \
197
+ "allow_unauthenticated_build=#{allow_unauthenticated_build.inspect} " \
198
+ "minimum_quote_validity_seconds=#{minimum_quote_validity_seconds.inspect} " \
199
+ "timeout=#{timeout.inspect} http_adapter=#{adapter.inspect}>"
200
+ end
201
+
202
+ alias to_s inspect
203
+
204
+ private
205
+
206
+ def fail_bridge(code)
207
+ raise BridgeError.new(code), cause: nil
208
+ end
209
+
210
+ def normalize_api_key(value)
211
+ return nil if value.nil?
212
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless value.is_a?(String)
213
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless value.ascii_only?
214
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if value.each_byte.any? { |byte| byte < 0x20 || byte == 0x7f }
215
+
216
+ value.empty? ? nil : value.dup.freeze
217
+ end
218
+
219
+ def normalize_endpoint(value)
220
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless value.is_a?(String)
221
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if value.empty? || value.strip != value
222
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if value.each_byte.any? { |byte| byte < 0x20 || byte == 0x7f }
223
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless value.match?(%r{\A[a-z][a-z\d+.-]*://}i)
224
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if value.include?("?") || value.include?("#")
225
+
226
+ uri = URI.parse(value)
227
+ scheme = uri.scheme.to_s.downcase
228
+ hostname = uri.hostname.to_s.downcase
229
+ authority = value.split("://", 2).fetch(1).to_s.split(/[\/?#]/, 2).first.to_s
230
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if authority.empty? || authority.include?("@")
231
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if uri.host.to_s.empty? || uri.user || uri.password
232
+ valid_http = scheme == "https" || (scheme == "http" && %w[localhost 127.0.0.1 ::1].include?(hostname))
233
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless valid_http
234
+ begin
235
+ port = uri.port
236
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if port && (port.negative? || port > 65_535)
237
+ rescue URI::InvalidURIError, ArgumentError
238
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
239
+ end
240
+ path = uri.path.to_s.sub(%r{/+\z}, "")
241
+ uri.scheme = scheme
242
+ uri.path = path.empty? ? "/" : path
243
+ uri.query = nil
244
+ uri.fragment = nil
245
+ uri.to_s.freeze
246
+ rescue URI::InvalidURIError, ArgumentError
247
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
248
+ end
249
+ end
250
+
251
+ class MayanBridgeBodyTooLarge < StandardError; end
252
+
253
+ # The bridge owns this small adapter so the response limit is enforced while
254
+ # Net::HTTP reads chunks. It never follows redirects and keeps the timeout
255
+ # active for connection, headers, and body reads.
256
+ class MayanBridgeHttpAdapter
257
+ def initialize(max_response_bytes)
258
+ @max_response_bytes = max_response_bytes
259
+ end
260
+
261
+ def request(method:, url:, headers:, body: nil, timeout: MayanSwiftV2BridgeConfig::DEFAULT_TIMEOUT)
262
+ uri = URI.parse(url)
263
+ request = request_class(method).new(uri.request_uri, headers)
264
+ request.body = body unless body.nil?
265
+ response = nil
266
+ response_body = String.new(encoding: Encoding::BINARY)
267
+ start(uri, timeout) do |http|
268
+ http.request(request) do |candidate|
269
+ response = candidate
270
+ candidate.read_body do |chunk|
271
+ response_body << chunk.b
272
+ raise MayanBridgeBodyTooLarge if response_body.bytesize > @max_response_bytes
273
+ end
274
+ end
275
+ end
276
+ HttpResponse.new(status: response.code.to_i, body: response_body)
277
+ rescue MayanBridgeBodyTooLarge
278
+ raise
279
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
280
+ raise TimeoutError, timeout, cause: nil
281
+ rescue IOError, EOFError, SocketError, SystemCallError, OpenSSL::SSL::SSLError, URI::InvalidURIError
282
+ raise TransportError, "Unable to reach bridge provider", cause: nil
283
+ end
284
+
285
+ private
286
+
287
+ def start(uri, timeout, &block)
288
+ Net::HTTP.start(
289
+ uri.host,
290
+ uri.port,
291
+ use_ssl: uri.scheme == "https",
292
+ open_timeout: timeout,
293
+ read_timeout: timeout,
294
+ write_timeout: timeout,
295
+ &block
296
+ )
297
+ end
298
+
299
+ def request_class(method)
300
+ { get: Net::HTTP::Get, post: Net::HTTP::Post }.fetch(method.to_sym)
301
+ end
302
+ end
303
+
304
+ JsonNode = Struct.new(:value, :start, :end, :object_entries, :array_items, :raw_number,
305
+ keyword_init: true)
306
+ DirectionFacts = Struct.new(
307
+ :bridge_capability_id, :source_chain_id, :destination_chain_id,
308
+ :source_token_deployment_id, :destination_token_deployment_id,
309
+ :source_token_address, :destination_token_address,
310
+ :source_token_standard, :destination_token_standard,
311
+ :source_provider_chain_id, :destination_provider_chain_id,
312
+ :source_wormhole_chain_id, :destination_wormhole_chain_id,
313
+ :source_name, :destination_name, :source_eurc_mint, :destination_eurc_mint,
314
+ :source_usdc_deployment_id, :source_usdc_address, :source_usdc_standard,
315
+ :swift_contract, keyword_init: true
316
+ )
317
+ NormalizedRoute = Struct.new(:request, :facts, :capability, keyword_init: true)
318
+
319
+ class StrictJsonParser
320
+ NUMBER = /\G-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.freeze
321
+ MAX_DEPTH = 32
322
+
323
+ def initialize(source)
324
+ @source = source
325
+ @index = 0
326
+ end
327
+
328
+ def parse
329
+ skip_whitespace
330
+ result = value(0)
331
+ skip_whitespace
332
+ invalid unless @index == @source.length
333
+ result
334
+ rescue BridgeError => error
335
+ ERPC.raise_safe_bridge_error(error)
336
+ rescue StandardError
337
+ invalid
338
+ end
339
+
340
+ private
341
+
342
+ def value(depth)
343
+ invalid if depth > MAX_DEPTH
344
+ start = @index
345
+ character = @source[@index]
346
+ case character
347
+ when "{"
348
+ object(start, depth)
349
+ when "["
350
+ array(start, depth)
351
+ when '"'
352
+ JsonNode.new(value: string, start: start, end: @index)
353
+ else
354
+ if @source[@index, 4] == "true"
355
+ @index += 4
356
+ JsonNode.new(value: true, start: start, end: @index)
357
+ elsif @source[@index, 5] == "false"
358
+ @index += 5
359
+ JsonNode.new(value: false, start: start, end: @index)
360
+ elsif @source[@index, 4] == "null"
361
+ @index += 4
362
+ JsonNode.new(value: nil, start: start, end: @index)
363
+ elsif character == "-" || (character && character.match?(/[0-9]/))
364
+ raw = number
365
+ numeric = if raw.include?(".") || raw.match?(/[eE]/)
366
+ Float(raw)
367
+ else
368
+ integer = Integer(raw, 10)
369
+ invalid unless Float(raw).finite?
370
+ integer
371
+ end
372
+ invalid unless numeric.finite?
373
+ JsonNode.new(value: numeric, start: start, end: @index, raw_number: raw)
374
+ else
375
+ invalid
376
+ end
377
+ end
378
+ end
379
+
380
+ def object(start, depth)
381
+ @index += 1
382
+ skip_whitespace
383
+ values = {}
384
+ entries = {}
385
+ return finish_object(start, values, entries) if @source[@index] == "}"
386
+
387
+ loop do
388
+ invalid unless @source[@index] == '"'
389
+ key = string
390
+ invalid if entries.key?(key)
391
+ skip_whitespace
392
+ invalid unless @source[@index] == ":"
393
+ @index += 1
394
+ skip_whitespace
395
+ child = value(depth + 1)
396
+ values[key] = child.value
397
+ entries[key] = child
398
+ skip_whitespace
399
+ delimiter = @source[@index]
400
+ return finish_object(start, values, entries) if delimiter == "}"
401
+ invalid unless delimiter == ","
402
+ @index += 1
403
+ skip_whitespace
404
+ end
405
+ end
406
+
407
+ def finish_object(start, values, entries)
408
+ @index += 1
409
+ JsonNode.new(value: values, start: start, end: @index, object_entries: entries)
410
+ end
411
+
412
+ def array(start, depth)
413
+ @index += 1
414
+ skip_whitespace
415
+ values = []
416
+ entries = []
417
+ if @source[@index] == "]"
418
+ @index += 1
419
+ return JsonNode.new(value: values, start: start, end: @index, array_items: entries)
420
+ end
421
+ loop do
422
+ child = value(depth + 1)
423
+ values << child.value
424
+ entries << child
425
+ skip_whitespace
426
+ delimiter = @source[@index]
427
+ if delimiter == "]"
428
+ @index += 1
429
+ return JsonNode.new(value: values, start: start, end: @index, array_items: entries)
430
+ end
431
+ invalid unless delimiter == ","
432
+ @index += 1
433
+ skip_whitespace
434
+ end
435
+ end
436
+
437
+ def string
438
+ start = @index
439
+ @index += 1
440
+ while @index < @source.length
441
+ character = @source[@index]
442
+ if character == '"'
443
+ @index += 1
444
+ raw = @source[start...@index]
445
+ begin
446
+ parsed = JSON.parse(raw)
447
+ rescue JSON::ParserError
448
+ invalid
449
+ end
450
+ invalid unless parsed.is_a?(String)
451
+ return parsed
452
+ end
453
+ if character == "\\"
454
+ @index += 1
455
+ invalid if @index >= @source.length
456
+ escape = @source[@index]
457
+ if escape == "u"
458
+ digits = @source[(@index + 1), 4]
459
+ invalid unless digits && digits.match?(/\A[0-9a-fA-F]{4}\z/)
460
+ @index += 5
461
+ next
462
+ end
463
+ invalid unless escape && '"\\/bfnrt'.include?(escape)
464
+ @index += 1
465
+ next
466
+ end
467
+ invalid if character.nil? || character.ord < 0x20
468
+ @index += 1
469
+ end
470
+ invalid
471
+ end
472
+
473
+ def number
474
+ match = NUMBER.match(@source, @index)
475
+ invalid unless match
476
+ raw = match[0]
477
+ @index = match.end(0)
478
+ raw
479
+ end
480
+
481
+ def skip_whitespace
482
+ @index += 1 while @index < @source.length && " \n\r\t".include?(@source[@index])
483
+ end
484
+
485
+ def invalid
486
+ raise BridgeError.new(BridgeErrorCode::PROVIDER_INVALID_RESPONSE), cause: nil
487
+ end
488
+ end
489
+
490
+ class MayanSwiftV2BridgeClient
491
+ ETHEREUM_CHAIN_ID = TokenChainIDs::ETHEREUM_MAINNET
492
+ SOLANA_CHAIN_ID = TokenChainIDs::SOLANA_MAINNET
493
+ ETHEREUM_NAME = "ethereum"
494
+ SOLANA_NAME = "solana"
495
+ ETHEREUM_PROVIDER_CHAIN_ID = 1
496
+ SOLANA_PROVIDER_CHAIN_ID = 0
497
+ ETHEREUM_WORMHOLE_CHAIN_ID = 2
498
+ SOLANA_WORMHOLE_CHAIN_ID = 1
499
+ ETHEREUM_EURC_DEPLOYMENT_ID = "deployment-0011"
500
+ SOLANA_EURC_DEPLOYMENT_ID = "deployment-0013"
501
+ ETHEREUM_USDC_DEPLOYMENT_ID = "deployment-0008"
502
+ SOLANA_USDC_DEPLOYMENT_ID = "deployment-0010"
503
+ ETHEREUM_EURC_ADDRESS = "0x1abaea1f7c830bd89acc67ec4af516284b1bc33c"
504
+ SOLANA_EURC_ADDRESS = "HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr"
505
+ ETHEREUM_USDC_ADDRESS = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
506
+ SOLANA_USDC_ADDRESS = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
507
+ ETHEREUM_SWIFT_CONTRACT = "0x40ffe85a28dc9993541449464d7529a922142960"
508
+ SOLANA_SWIFT_PROGRAM = "mayan34VedncxdK2XobtvWFDXQASUTBXhUVzt2kKgny"
509
+ ETHEREUM_FORWARDER = "0x337685fdab40d39bd02028545a4ffa7d287cc3e2"
510
+ SOLANA_JUPITER_V6 = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"
511
+ ETHEREUM_FORWARDER_SELECTOR = "0x30dedc57"
512
+ DEFAULT_BUILDER_ENDPOINT = MayanSwiftV2BridgeConfig::DEFAULT_BUILDER_ENDPOINT
513
+ DEFAULT_EXPLORER_ENDPOINT = MayanSwiftV2BridgeConfig::DEFAULT_EXPLORER_ENDPOINT
514
+ DEFAULT_TIMEOUT = MayanSwiftV2BridgeConfig::DEFAULT_TIMEOUT
515
+ MAX_RESPONSE_BYTES = 1024 * 1024
516
+ MAX_RAW_QUOTE_BYTES = 256 * 1024
517
+ MAX_QUOTES = 16
518
+ UINT64_MAX = (1 << 64) - 1
519
+ MAX_SAFE_INTEGER = (1 << 53) - 1
520
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
521
+ BASE58_INDEX = BASE58_ALPHABET.each_char.with_index.to_h.freeze
522
+ BASE_DEPENDENCIES = [
523
+ "mayan-hosted-quote-api",
524
+ "mayan-hosted-transaction-builder",
525
+ "mayan-hosted-source-swap-builder",
526
+ "swift-auction-solvers",
527
+ "relayers",
528
+ "wormhole-guardian-messaging",
529
+ "mayan-explorer-indexer"
530
+ ].freeze
531
+
532
+ EVM_ADDRESS = /\A0x[0-9a-fA-F]{40}\z/.freeze
533
+ EVM_HASH = /\A0x[0-9a-fA-F]{64}\z/.freeze
534
+ QUOTE_ID = /\A0x[0-9a-fA-F]{32}\z/.freeze
535
+ EVM_SIGNATURE = /\A0x[0-9a-fA-F]{130}\z/.freeze
536
+ HEX_BYTES = /\A0x[0-9a-fA-F]*\z/.freeze
537
+ POSITIVE_UINT64 = /\A[1-9][0-9]*\z/.freeze
538
+ CANONICAL_UINT64 = /\A(?:0|[1-9][0-9]*)\z/.freeze
539
+ BASE64 = /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/.freeze
540
+ QUOTE_KEYS = %w[
541
+ quoteKind providerId sourceChainId destinationChainId sourceTokenDeploymentId
542
+ destinationTokenDeploymentId amountIn expectedAmountOut minimumAmountOut
543
+ minimumReceived deadline slippageBps quoteId providerSignature sourceSwap
544
+ dependencies quoteVerification rawSignedQuoteJson
545
+ ].freeze
546
+ SOURCE_SWAP_KEYS = %w[
547
+ required inputTokenDeploymentId intermediateTokenDeploymentId
548
+ intermediateTokenAddress intermediateTokenStandard intermediateTokenDecimals
549
+ providerMinimumAmount routerKind routerAddress
550
+ ].freeze
551
+
552
+ attr_reader :config
553
+
554
+ def initialize(config = nil, http_adapter: nil, adapter: nil, **config_keywords)
555
+ if !config_keywords.empty?
556
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless config.nil?
557
+ config = config_keywords
558
+ end
559
+ @config = normalize_config(config, http_adapter || adapter)
560
+ @http_adapter = @config.http_adapter || MayanBridgeHttpAdapter.new(MAX_RESPONSE_BYTES)
561
+ @clock = -> { Time.now.to_i }
562
+ @closed = false
563
+ end
564
+
565
+ def inspect
566
+ "#<#{self.class} config=#{config.inspect}>"
567
+ end
568
+
569
+ def quote_exact_input(request, options = nil, **keyword_options)
570
+ route = validate_route(deep_dup(request))
571
+ check_aborted(options || keyword_options)
572
+ body = quote_request_body(route)
573
+ response_text = provider_request(
574
+ endpoint_with_path(@config.builder_endpoint, "/quote"),
575
+ :post,
576
+ body,
577
+ include_builder_key: false,
578
+ operation: :quote,
579
+ options: options || keyword_options
580
+ )
581
+ root = parse_provider_response(response_text)
582
+ selected = quotes_from_response(root, response_text, route.request, route.facts)
583
+ check_aborted(options || keyword_options)
584
+ deep_freeze(selected)
585
+ end
586
+
587
+ alias quoteExactInput quote_exact_input
588
+
589
+ def build_unsigned(request, options = nil, **keyword_options)
590
+ snapshot = build_request_snapshot(request)
591
+ route, quote = build_route_from_quote(snapshot.fetch("quote"))
592
+ reject_address_from_other_chain(snapshot.fetch("swapperAddress"), route.facts.source_chain_id)
593
+ source_address = normalize_chain_address(
594
+ snapshot.fetch("swapperAddress"), route.facts.source_chain_id, BridgeErrorCode::INVALID_ARGUMENT
595
+ )
596
+ destination_address = normalize_destination_address(
597
+ snapshot.fetch("destinationAddress"), route.facts.destination_chain_id
598
+ )
599
+ refund_address = if snapshot.key?("refundAddress")
600
+ normalize_chain_address(
601
+ snapshot["refundAddress"], route.facts.source_chain_id, BridgeErrorCode::INVALID_ARGUMENT
602
+ )
603
+ end
604
+ check_aborted(options || keyword_options)
605
+ if @config.builder_api_key.nil? && !@config.allow_unauthenticated_build
606
+ fail_bridge(BridgeErrorCode::PROVIDER_AUTH_REQUIRED)
607
+ end
608
+ quote = validate_raw_quote_for_build(quote, route)
609
+ params = { "swapperAddress" => source_address, "destinationAddress" => destination_address }
610
+ params["signerChainId"] = 1 if route.facts.source_chain_id == ETHEREUM_CHAIN_ID
611
+ params["swiftRefundAddress"] = refund_address unless refund_address.nil?
612
+ body = %({"quote":#{quote.fetch("rawSignedQuoteJson")},"params":#{JSON.generate(params)}})
613
+ response_text = provider_request(
614
+ endpoint_with_path(@config.builder_endpoint, "/build"),
615
+ :post,
616
+ body,
617
+ include_builder_key: true,
618
+ operation: :build,
619
+ options: options || keyword_options
620
+ )
621
+ root = parse_provider_response(response_text)
622
+ ensure_quote_deadline(normalize_positive_uint64(quote.fetch("deadline"), BridgeErrorCode::QUOTE_EXPIRED).last)
623
+ result = validate_build_response(root, response_text, quote, route.facts, source_address)
624
+ check_aborted(options || keyword_options)
625
+ deep_freeze(result)
626
+ end
627
+
628
+ alias buildUnsigned build_unsigned
629
+
630
+ def get_status(request, options = nil, **keyword_options)
631
+ normalized = normalize_status_request(request)
632
+ check_aborted(options || keyword_options)
633
+ encoded = URLs.escape_path(normalized.fetch("sourceTransactionHash"))
634
+ response_text = provider_request(
635
+ endpoint_with_path(@config.explorer_endpoint, "/swap/trx/#{encoded}"),
636
+ :get,
637
+ nil,
638
+ include_builder_key: false,
639
+ operation: :status,
640
+ options: options || keyword_options
641
+ )
642
+ result = status_from_response(parse_provider_response(response_text), response_text, normalized)
643
+ check_aborted(options || keyword_options)
644
+ deep_freeze(result)
645
+ end
646
+
647
+ alias getStatus get_status
648
+
649
+ def close
650
+ return if @closed
651
+
652
+ @closed = true
653
+ # The default adapter has no persistent client owned by this object, and
654
+ # externally supplied adapters must remain open for their owner.
655
+ nil
656
+ end
657
+
658
+ private
659
+
660
+ def normalize_config(value, injected_adapter)
661
+ if value.nil?
662
+ config = MayanSwiftV2BridgeConfig.new(http_adapter: injected_adapter)
663
+ elsif value.is_a?(MayanSwiftV2BridgeConfig)
664
+ if injected_adapter.nil?
665
+ config = value
666
+ else
667
+ config = MayanSwiftV2BridgeConfig.new(
668
+ builder_endpoint: value.builder_endpoint,
669
+ explorer_endpoint: value.explorer_endpoint,
670
+ builder_api_key: value.builder_api_key,
671
+ allow_unauthenticated_build: value.allow_unauthenticated_build,
672
+ minimum_quote_validity_seconds: value.minimum_quote_validity_seconds,
673
+ timeout: value.timeout,
674
+ http_adapter: injected_adapter
675
+ )
676
+ end
677
+ elsif value.is_a?(Hash)
678
+ source = value.dup
679
+ aliases = {
680
+ "builderEndpoint" => :builder_endpoint,
681
+ "explorerEndpoint" => :explorer_endpoint,
682
+ "builderApiKey" => :builder_api_key,
683
+ "allowUnauthenticatedBuild" => :allow_unauthenticated_build,
684
+ "minimumQuoteValiditySeconds" => :minimum_quote_validity_seconds,
685
+ "timeoutMs" => :timeout_ms,
686
+ "httpAdapter" => :http_adapter
687
+ }
688
+ normalized = {}
689
+ source.each do |key, item|
690
+ symbol = key.is_a?(String) ? aliases.fetch(key, key.to_sym) : key
691
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless symbol.is_a?(Symbol)
692
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if normalized.key?(symbol)
693
+ normalized[symbol] = item
694
+ end
695
+ if injected_adapter
696
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) if normalized.key?(:http_adapter)
697
+ normalized[:http_adapter] = injected_adapter
698
+ end
699
+ config = MayanSwiftV2BridgeConfig.new(**normalized)
700
+ else
701
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
702
+ end
703
+ config
704
+ rescue ArgumentError, TypeError
705
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
706
+ end
707
+
708
+ def fail_bridge(code, status = nil)
709
+ raise BridgeError.new(code, status), cause: nil
710
+ end
711
+
712
+ def require_record(value, code)
713
+ fail_bridge(code) unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
714
+ value
715
+ end
716
+
717
+ def require_string(value, code, allow_empty: false)
718
+ fail_bridge(code) unless value.is_a?(String) && (allow_empty || !value.empty?)
719
+ value
720
+ end
721
+
722
+ def exact_keys(value, expected, code)
723
+ fail_bridge(code) unless value.keys.sort == expected.sort && value.length == expected.length
724
+ end
725
+
726
+ def normalize_evm_address(value, code)
727
+ address = require_string(value, code)
728
+ fail_bridge(code) unless EVM_ADDRESS.match?(address) && address.downcase != "0x#{'0' * 40}"
729
+ address.downcase
730
+ end
731
+
732
+ def normalize_positive_uint64(value, code)
733
+ text = require_string(value, code)
734
+ fail_bridge(code) unless text.length <= 20 && POSITIVE_UINT64.match?(text)
735
+ parsed = Integer(text, 10)
736
+ fail_bridge(code) if parsed <= 0 || parsed > UINT64_MAX
737
+ [text, parsed]
738
+ rescue ArgumentError
739
+ fail_bridge(code)
740
+ end
741
+
742
+ def normalize_canonical_uint64(value, code)
743
+ text = require_string(value, code)
744
+ fail_bridge(code) unless text.length <= 20 && CANONICAL_UINT64.match?(text)
745
+ parsed = Integer(text, 10)
746
+ fail_bridge(code) if parsed > UINT64_MAX
747
+ [text, parsed]
748
+ rescue ArgumentError
749
+ fail_bridge(code)
750
+ end
751
+
752
+ def normalize_slippage(value, code)
753
+ fail_bridge(code) unless value.is_a?(Integer) && !value.is_a?(TrueClass) && value.between?(0, 500)
754
+ value
755
+ end
756
+
757
+ def direction_facts(source_chain_id, destination_chain_id)
758
+ if source_chain_id == ETHEREUM_CHAIN_ID && destination_chain_id == SOLANA_CHAIN_ID
759
+ return DirectionFacts.new(
760
+ bridge_capability_id: "bridge-mayan-swift-v2-eurc-eth-sol",
761
+ source_chain_id: source_chain_id, destination_chain_id: destination_chain_id,
762
+ source_token_deployment_id: ETHEREUM_EURC_DEPLOYMENT_ID,
763
+ destination_token_deployment_id: SOLANA_EURC_DEPLOYMENT_ID,
764
+ source_token_address: ETHEREUM_EURC_ADDRESS, destination_token_address: SOLANA_EURC_ADDRESS,
765
+ source_token_standard: "erc20", destination_token_standard: "spl-token",
766
+ source_provider_chain_id: ETHEREUM_PROVIDER_CHAIN_ID, destination_provider_chain_id: SOLANA_PROVIDER_CHAIN_ID,
767
+ source_wormhole_chain_id: ETHEREUM_WORMHOLE_CHAIN_ID, destination_wormhole_chain_id: SOLANA_WORMHOLE_CHAIN_ID,
768
+ source_name: ETHEREUM_NAME, destination_name: SOLANA_NAME,
769
+ source_eurc_mint: "", destination_eurc_mint: SOLANA_EURC_ADDRESS,
770
+ source_usdc_deployment_id: ETHEREUM_USDC_DEPLOYMENT_ID,
771
+ source_usdc_address: ETHEREUM_USDC_ADDRESS, source_usdc_standard: "erc20",
772
+ swift_contract: ETHEREUM_SWIFT_CONTRACT
773
+ )
774
+ end
775
+ if source_chain_id == SOLANA_CHAIN_ID && destination_chain_id == ETHEREUM_CHAIN_ID
776
+ return DirectionFacts.new(
777
+ bridge_capability_id: "bridge-mayan-swift-v2-eurc-sol-eth",
778
+ source_chain_id: source_chain_id, destination_chain_id: destination_chain_id,
779
+ source_token_deployment_id: SOLANA_EURC_DEPLOYMENT_ID,
780
+ destination_token_deployment_id: ETHEREUM_EURC_DEPLOYMENT_ID,
781
+ source_token_address: SOLANA_EURC_ADDRESS, destination_token_address: ETHEREUM_EURC_ADDRESS,
782
+ source_token_standard: "spl-token", destination_token_standard: "erc20",
783
+ source_provider_chain_id: SOLANA_PROVIDER_CHAIN_ID, destination_provider_chain_id: ETHEREUM_PROVIDER_CHAIN_ID,
784
+ source_wormhole_chain_id: SOLANA_WORMHOLE_CHAIN_ID, destination_wormhole_chain_id: ETHEREUM_WORMHOLE_CHAIN_ID,
785
+ source_name: SOLANA_NAME, destination_name: ETHEREUM_NAME,
786
+ source_eurc_mint: SOLANA_EURC_ADDRESS, destination_eurc_mint: "",
787
+ source_usdc_deployment_id: SOLANA_USDC_DEPLOYMENT_ID,
788
+ source_usdc_address: SOLANA_USDC_ADDRESS, source_usdc_standard: "spl-token",
789
+ swift_contract: SOLANA_SWIFT_PROGRAM
790
+ )
791
+ end
792
+ fail_bridge(BridgeErrorCode::UNSUPPORTED_ROUTE)
793
+ end
794
+
795
+ def catalog_token_matches?(deployment_id, chain_id, address, standard)
796
+ token = TokenCatalog.get_token_deployment(deployment_id)
797
+ return false unless token && token[:status] == "active" && token[:address].is_a?(String)
798
+
799
+ address_matches = chain_id == ETHEREUM_CHAIN_ID ? token[:address].casecmp?(address) : token[:address] == address
800
+ token[:deployment_id] == deployment_id && token[:chain_id] == chain_id && address_matches &&
801
+ token[:standard] == standard && token[:decimals] == 6
802
+ end
803
+
804
+ def capability_records
805
+ parsed = JSON.parse(BRIDGE_CAPABILITIES_JSON)
806
+ parsed.is_a?(Array) ? parsed : []
807
+ rescue JSON::ParserError
808
+ []
809
+ end
810
+
811
+ def validate_capability(facts)
812
+ capability = capability_records.find do |entry|
813
+ entry.is_a?(Hash) && entry["bridgeCapabilityId"] == facts.bridge_capability_id &&
814
+ entry["sourceChainId"] == facts.source_chain_id && entry["destinationChainId"] == facts.destination_chain_id &&
815
+ entry["sourceTokenDeploymentId"] == facts.source_token_deployment_id &&
816
+ entry["destinationTokenDeploymentId"] == facts.destination_token_deployment_id
817
+ end
818
+ fail_bridge(BridgeErrorCode::UNSUPPORTED_ROUTE) unless capability
819
+ expected = {
820
+ "bridgeCapabilityId" => facts.bridge_capability_id,
821
+ "providerId" => "mayan-swift-v2", "capabilityKind" => "external-provider-dynamic",
822
+ "sourceChainId" => facts.source_chain_id, "destinationChainId" => facts.destination_chain_id,
823
+ "sourceTokenDeploymentId" => facts.source_token_deployment_id,
824
+ "destinationTokenDeploymentId" => facts.destination_token_deployment_id,
825
+ "sourceTokenAddress" => facts.source_token_address, "destinationTokenAddress" => facts.destination_token_address,
826
+ "sourceTokenStandard" => facts.source_token_standard, "destinationTokenStandard" => facts.destination_token_standard,
827
+ "sourceTokenDecimals" => 6, "destinationTokenDecimals" => 6,
828
+ "sourceProviderChainName" => facts.source_name, "destinationProviderChainName" => facts.destination_name,
829
+ "sourceProviderChainId" => facts.source_provider_chain_id,
830
+ "destinationProviderChainId" => facts.destination_provider_chain_id,
831
+ "sourceWormholeChainId" => facts.source_wormhole_chain_id,
832
+ "destinationWormholeChainId" => facts.destination_wormhole_chain_id,
833
+ "sourceUsdcDeploymentId" => facts.source_usdc_deployment_id,
834
+ "sourceUsdcAddress" => facts.source_usdc_address, "sourceUsdcStandard" => facts.source_usdc_standard,
835
+ "sourceUsdcDecimals" => 6, "swiftContract" => facts.swift_contract,
836
+ "forwarderAddress" => facts.source_chain_id == ETHEREUM_CHAIN_ID ? ETHEREUM_FORWARDER : nil,
837
+ "forwarderFunctionSelector" => facts.source_chain_id == ETHEREUM_CHAIN_ID ? ETHEREUM_FORWARDER_SELECTOR : nil,
838
+ "jupiterProgramAddress" => facts.source_chain_id == SOLANA_CHAIN_ID ? SOLANA_JUPITER_V6 : nil,
839
+ "builderEndpoint" => DEFAULT_BUILDER_ENDPOINT, "explorerEndpoint" => DEFAULT_EXPLORER_ENDPOINT,
840
+ "dependencies" => facts.source_chain_id == SOLANA_CHAIN_ID ? BASE_DEPENDENCIES + ["jupiter-v6-source-swap"] : BASE_DEPENDENCIES,
841
+ "status" => "active"
842
+ }
843
+ fail_bridge(BridgeErrorCode::UNSUPPORTED_ROUTE) unless strict_equal?(capability, expected)
844
+ deep_freeze(capability)
845
+ end
846
+
847
+ def validate_route(value)
848
+ request = require_record(value, BridgeErrorCode::INVALID_ARGUMENT)
849
+ exact_keys(request, %w[sourceChainId destinationChainId sourceTokenDeploymentId destinationTokenDeploymentId amountIn slippageBps], BridgeErrorCode::INVALID_ARGUMENT)
850
+ source_chain_id = require_string(request["sourceChainId"], BridgeErrorCode::INVALID_ARGUMENT)
851
+ destination_chain_id = require_string(request["destinationChainId"], BridgeErrorCode::INVALID_ARGUMENT)
852
+ source_token_id = require_string(request["sourceTokenDeploymentId"], BridgeErrorCode::INVALID_ARGUMENT)
853
+ destination_token_id = require_string(request["destinationTokenDeploymentId"], BridgeErrorCode::INVALID_ARGUMENT)
854
+ amount = normalize_positive_uint64(request["amountIn"], BridgeErrorCode::INVALID_ARGUMENT).first
855
+ slippage = normalize_slippage(request["slippageBps"], BridgeErrorCode::INVALID_ARGUMENT)
856
+ facts = direction_facts(source_chain_id, destination_chain_id)
857
+ fail_bridge(BridgeErrorCode::UNSUPPORTED_ROUTE) unless source_token_id == facts.source_token_deployment_id && destination_token_id == facts.destination_token_deployment_id
858
+ unless catalog_token_matches?(facts.source_token_deployment_id, facts.source_chain_id, facts.source_token_address, facts.source_token_standard) &&
859
+ catalog_token_matches?(facts.destination_token_deployment_id, facts.destination_chain_id, facts.destination_token_address, facts.destination_token_standard) &&
860
+ catalog_token_matches?(facts.source_usdc_deployment_id, facts.source_chain_id, facts.source_usdc_address, facts.source_usdc_standard)
861
+ fail_bridge(BridgeErrorCode::UNSUPPORTED_ROUTE)
862
+ end
863
+ capability = validate_capability(facts)
864
+ NormalizedRoute.new(
865
+ request: {
866
+ "sourceChainId" => source_chain_id, "destinationChainId" => destination_chain_id,
867
+ "sourceTokenDeploymentId" => source_token_id, "destinationTokenDeploymentId" => destination_token_id,
868
+ "amountIn" => amount, "slippageBps" => slippage
869
+ }, facts: facts, capability: capability
870
+ )
871
+ end
872
+
873
+ def parse_provider_response(text)
874
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless text.is_a?(String)
875
+ StrictJsonParser.new(text).parse
876
+ rescue BridgeError => error
877
+ ERPC.raise_safe_bridge_error(error)
878
+ rescue StandardError
879
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE)
880
+ end
881
+
882
+ def object_entry(node, key)
883
+ node.object_entries&.fetch(key, nil)
884
+ end
885
+
886
+ def object_value(node, key)
887
+ entry = object_entry(node, key)
888
+ entry ? entry.value : :__bridge_missing__
889
+ end
890
+
891
+ def required_node(node, key)
892
+ entry = object_entry(node, key)
893
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless entry
894
+ entry
895
+ end
896
+
897
+ def provider_string(node, key, allow_empty: false)
898
+ value = required_node(node, key).value
899
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless value.is_a?(String) && (allow_empty || !value.empty?)
900
+ value
901
+ end
902
+
903
+ def provider_boolean(node, key)
904
+ value = required_node(node, key).value
905
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless value == true || value == false
906
+ value
907
+ end
908
+
909
+ def provider_number(node, key)
910
+ value = required_node(node, key).value
911
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless value.is_a?(Numeric) && value.finite?
912
+ value
913
+ end
914
+
915
+ def provider_integer(node, key)
916
+ value = provider_number(node, key)
917
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless value.to_i == value && value.to_i.abs <= MAX_SAFE_INTEGER
918
+ value.to_i
919
+ end
920
+
921
+ def provider_uint64(node, key, positive:)
922
+ value = required_node(node, key).value
923
+ positive ? normalize_positive_uint64(value, BridgeErrorCode::PROVIDER_INVALID_RESPONSE) : normalize_canonical_uint64(value, BridgeErrorCode::PROVIDER_INVALID_RESPONSE)
924
+ end
925
+
926
+ def provider_address_equals?(value, expected)
927
+ return false unless value.is_a?(String)
928
+ EVM_ADDRESS.match?(value) ? value.casecmp?(expected) : value == expected
929
+ end
930
+
931
+ def provider_address(node, key, expected)
932
+ value = provider_string(node, key)
933
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_address_equals?(value, expected)
934
+ EVM_ADDRESS.match?(value) ? value.downcase : value
935
+ end
936
+
937
+ def provider_token(node, address:, standard:, chain_id:, wormhole_chain_id:, mint:)
938
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless node.object_entries && node.value.is_a?(Hash)
939
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_address_equals?(provider_string(node, "contract"), address)
940
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "mint", allow_empty: true) == mint
941
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_address_equals?(provider_string(node, "realOriginContractAddress"), address)
942
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "name") == "EuroC"
943
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "standard") == standard
944
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(node, "chainId") == chain_id
945
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(node, "wChainId") == wormhole_chain_id
946
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(node, "realOriginChainId") == wormhole_chain_id
947
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(node, "decimals") == 6
948
+ end
949
+
950
+ def ensure_quote_deadline(deadline)
951
+ fail_bridge(BridgeErrorCode::QUOTE_EXPIRED) if deadline < @clock.call.to_i + @config.minimum_quote_validity_seconds
952
+ end
953
+
954
+ def validate_provider_quote(node, text, request, facts)
955
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless node.object_entries && node.value.is_a?(Hash)
956
+ raw = text[node.start...node.end]
957
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if raw.bytesize > MAX_RAW_QUOTE_BYTES
958
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "type") == "SWIFT"
959
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "swiftVersion") == "V2"
960
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if provider_boolean(node, "gasless")
961
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "fromChain") == facts.source_name
962
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "toChain") == facts.destination_name
963
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(node, "slippageBps") == request.fetch("slippageBps")
964
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if provider_boolean(node, "onlyBridging")
965
+
966
+ effective_amount = provider_uint64(node, "effectiveAmountIn64", positive: true)
967
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless effective_amount.first == request.fetch("amountIn")
968
+ expected_amount = provider_uint64(node, "expectedAmountOutBaseUnits", positive: true)
969
+ minimum_amount = provider_uint64(node, "minAmountOutBaseUnits", positive: true)
970
+ minimum_received = provider_uint64(node, "minReceivedBaseUnits", positive: true)
971
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if minimum_amount.last > expected_amount.last || minimum_received.last > minimum_amount.last
972
+ deadline = provider_uint64(node, "deadline64", positive: true)
973
+ ensure_quote_deadline(deadline.last)
974
+
975
+ provider_token(required_node(node, "fromToken"), address: facts.source_token_address, standard: facts.source_chain_id == ETHEREUM_CHAIN_ID ? "erc20" : "spl", chain_id: facts.source_provider_chain_id, wormhole_chain_id: facts.source_wormhole_chain_id, mint: facts.source_eurc_mint)
976
+ provider_token(required_node(node, "toToken"), address: facts.destination_token_address, standard: facts.destination_chain_id == ETHEREUM_CHAIN_ID ? "erc20" : "spl", chain_id: facts.destination_provider_chain_id, wormhole_chain_id: facts.destination_wormhole_chain_id, mint: facts.destination_eurc_mint)
977
+ provider_standard = facts.source_chain_id == ETHEREUM_CHAIN_ID ? "erc20" : "spl"
978
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_address(node, "swiftInputContract", facts.source_usdc_address) == facts.source_usdc_address
979
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(node, "swiftInputContractStandard") == provider_standard
980
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(node, "swiftInputDecimals") == 6
981
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_address(node, "swiftMayanContract", facts.swift_contract) == facts.swift_contract
982
+
983
+ middle = required_node(node, "minMiddleAmount")
984
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless middle.raw_number && middle.value.is_a?(Numeric) && middle.value.finite? && middle.value.positive?
985
+ if facts.source_chain_id == ETHEREUM_CHAIN_ID
986
+ router_address = normalize_evm_address(provider_string(node, "evmSwapRouterAddress"), BridgeErrorCode::PROVIDER_INVALID_RESPONSE)
987
+ router_kind = "provider-selected-evm"
988
+ else
989
+ router_address = SOLANA_JUPITER_V6
990
+ router_kind = "jupiter-v6"
991
+ evm_router = object_value(node, "evmSwapRouterAddress")
992
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless evm_router == :__bridge_missing__ || evm_router.nil?
993
+ end
994
+ quote_id = provider_string(node, "quoteId")
995
+ signature = provider_string(node, "signature")
996
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless QUOTE_ID.match?(quote_id) && EVM_SIGNATURE.match?(signature)
997
+
998
+ dependencies = facts.source_chain_id == SOLANA_CHAIN_ID ? BASE_DEPENDENCIES + ["jupiter-v6-source-swap"] : BASE_DEPENDENCIES
999
+ {
1000
+ "quoteKind" => "mayan-swift-v2", "providerId" => "mayan-swift-v2",
1001
+ "sourceChainId" => request.fetch("sourceChainId"), "destinationChainId" => request.fetch("destinationChainId"),
1002
+ "sourceTokenDeploymentId" => facts.source_token_deployment_id,
1003
+ "destinationTokenDeploymentId" => facts.destination_token_deployment_id,
1004
+ "amountIn" => effective_amount.first, "expectedAmountOut" => expected_amount.first,
1005
+ "minimumAmountOut" => minimum_amount.first, "minimumReceived" => minimum_received.first,
1006
+ "deadline" => deadline.first, "slippageBps" => request.fetch("slippageBps"),
1007
+ "quoteId" => quote_id.downcase, "providerSignature" => signature.downcase,
1008
+ "sourceSwap" => {
1009
+ "required" => true, "inputTokenDeploymentId" => facts.source_token_deployment_id,
1010
+ "intermediateTokenDeploymentId" => facts.source_usdc_deployment_id,
1011
+ "intermediateTokenAddress" => facts.source_usdc_address,
1012
+ "intermediateTokenStandard" => provider_standard, "intermediateTokenDecimals" => 6,
1013
+ "providerMinimumAmount" => middle.raw_number,
1014
+ "routerKind" => router_kind, "routerAddress" => router_address
1015
+ },
1016
+ "dependencies" => dependencies,
1017
+ "quoteVerification" => "provider-signed-not-locally-verified",
1018
+ "rawSignedQuoteJson" => raw
1019
+ }
1020
+ end
1021
+
1022
+ def quote_request_body(route)
1023
+ JSON.generate(
1024
+ "fromToken" => route.facts.source_token_address,
1025
+ "fromChain" => route.facts.source_name,
1026
+ "toToken" => route.facts.destination_token_address,
1027
+ "toChain" => route.facts.destination_name,
1028
+ "amountIn64" => route.request.fetch("amountIn"),
1029
+ "slippageBps" => route.request.fetch("slippageBps"),
1030
+ "swift" => true, "mctp" => false, "fastMctp" => false, "wormhole" => false,
1031
+ "monoChain" => false, "gasless" => false, "fullList" => true,
1032
+ "guaranteedOutput" => true, "gasDrop" => 0
1033
+ )
1034
+ end
1035
+
1036
+ def quotes_from_response(root, text, request, facts)
1037
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless root.object_entries && root.value.is_a?(Hash)
1038
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless object_value(root, "success") == true
1039
+ quotes_node = required_node(root, "quotes")
1040
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless quotes_node.array_items && quotes_node.array_items.length <= MAX_QUOTES
1041
+ selected = []
1042
+ quotes_node.array_items.each do |node|
1043
+ next unless node.object_entries && node.value.is_a?(Hash)
1044
+ next unless node.value["type"] == "SWIFT" && node.value["swiftVersion"] == "V2" && node.value["gasless"] == false
1045
+
1046
+ selected << validate_provider_quote(node, text, request, facts)
1047
+ end
1048
+ fail_bridge(BridgeErrorCode::QUOTE_UNAVAILABLE) if selected.empty?
1049
+ selected.each { |quote| ensure_quote_deadline(normalize_positive_uint64(quote.fetch("deadline"), BridgeErrorCode::PROVIDER_INVALID_RESPONSE).last) }
1050
+ selected
1051
+ end
1052
+
1053
+ def build_request_snapshot(value)
1054
+ request = require_record(deep_dup(value), BridgeErrorCode::INVALID_ARGUMENT)
1055
+ exact_keys(request, %w[quote swapperAddress destinationAddress refundAddress].select { |key| request.key?(key) }, BridgeErrorCode::INVALID_ARGUMENT) if request.keys.any? { |key| !%w[quote swapperAddress destinationAddress refundAddress].include?(key) }
1056
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless request.key?("quote") && request.key?("swapperAddress") && request.key?("destinationAddress")
1057
+ quote = deep_dup(request.fetch("quote"))
1058
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless quote.is_a?(Hash)
1059
+ request.merge("quote" => quote)
1060
+ rescue TypeError, NoMethodError
1061
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT)
1062
+ end
1063
+
1064
+ def normalize_chain_address(value, chain_id, code)
1065
+ return normalize_evm_address(value, code) if chain_id == ETHEREUM_CHAIN_ID
1066
+
1067
+ address = require_string(value, code)
1068
+ base58_decode(address, 32, code)
1069
+ address
1070
+ end
1071
+
1072
+ def is_canonical_solana_address?(value)
1073
+ return false unless value.is_a?(String)
1074
+ base58_decode(value, 32, BridgeErrorCode::INVALID_ARGUMENT)
1075
+ true
1076
+ rescue BridgeError
1077
+ false
1078
+ end
1079
+
1080
+ def reject_address_from_other_chain(value, expected_chain_id)
1081
+ evm = value.is_a?(String) && EVM_ADDRESS.match?(value) && value.downcase != "0x#{'0' * 40}"
1082
+ solana = is_canonical_solana_address?(value)
1083
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH) if (expected_chain_id == ETHEREUM_CHAIN_ID && solana) || (expected_chain_id == SOLANA_CHAIN_ID && evm)
1084
+ end
1085
+
1086
+ def normalize_destination_address(value, destination_chain_id)
1087
+ normalize_chain_address(value, destination_chain_id, BridgeErrorCode::INVALID_ARGUMENT)
1088
+ end
1089
+
1090
+ def build_route_from_quote(quote)
1091
+ normalized = validate_normalized_quote_shape(quote, BridgeErrorCode::QUOTE_MISMATCH)
1092
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH) unless strict_equal?(quote, normalized)
1093
+ route = validate_route(
1094
+ "sourceChainId" => normalized.fetch("sourceChainId"),
1095
+ "destinationChainId" => normalized.fetch("destinationChainId"),
1096
+ "sourceTokenDeploymentId" => normalized.fetch("sourceTokenDeploymentId"),
1097
+ "destinationTokenDeploymentId" => normalized.fetch("destinationTokenDeploymentId"),
1098
+ "amountIn" => normalized.fetch("amountIn"), "slippageBps" => normalized.fetch("slippageBps")
1099
+ )
1100
+ [route, normalized]
1101
+ rescue BridgeError => error
1102
+ ERPC.raise_safe_bridge_error(error)
1103
+ rescue StandardError
1104
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH)
1105
+ end
1106
+
1107
+ def validate_normalized_quote_shape(value, code)
1108
+ quote = require_record(value, code)
1109
+ exact_keys(quote, QUOTE_KEYS, code)
1110
+ fail_bridge(code) unless quote["quoteKind"] == "mayan-swift-v2" && quote["providerId"] == "mayan-swift-v2"
1111
+ source_chain = require_string(quote["sourceChainId"], code)
1112
+ destination_chain = require_string(quote["destinationChainId"], code)
1113
+ facts = direction_facts(source_chain, destination_chain)
1114
+ fail_bridge(code) unless quote["sourceTokenDeploymentId"] == facts.source_token_deployment_id && quote["destinationTokenDeploymentId"] == facts.destination_token_deployment_id
1115
+ amount = normalize_positive_uint64(quote["amountIn"], code)
1116
+ expected_amount = normalize_positive_uint64(quote["expectedAmountOut"], code)
1117
+ minimum_amount = normalize_positive_uint64(quote["minimumAmountOut"], code)
1118
+ minimum_received = normalize_positive_uint64(quote["minimumReceived"], code)
1119
+ fail_bridge(code) if minimum_amount.last > expected_amount.last || minimum_received.last > minimum_amount.last
1120
+ deadline = normalize_positive_uint64(quote["deadline"], code)
1121
+ slippage = normalize_slippage(quote["slippageBps"], code)
1122
+ fail_bridge(code) unless quote["quoteVerification"] == "provider-signed-not-locally-verified"
1123
+ quote_id = require_string(quote["quoteId"], code)
1124
+ signature = require_string(quote["providerSignature"], code)
1125
+ fail_bridge(code) unless QUOTE_ID.match?(quote_id) && EVM_SIGNATURE.match?(signature)
1126
+ source_swap = require_record(quote["sourceSwap"], code)
1127
+ exact_keys(source_swap, SOURCE_SWAP_KEYS, code)
1128
+ provider_standard = facts.source_chain_id == ETHEREUM_CHAIN_ID ? "erc20" : "spl"
1129
+ fail_bridge(code) unless source_swap["required"] == true && source_swap["inputTokenDeploymentId"] == facts.source_token_deployment_id && source_swap["intermediateTokenDeploymentId"] == facts.source_usdc_deployment_id && source_swap["intermediateTokenAddress"] == facts.source_usdc_address && source_swap["intermediateTokenStandard"] == provider_standard && source_swap["intermediateTokenDecimals"] == 6
1130
+ provider_minimum = require_string(source_swap["providerMinimumAmount"], code)
1131
+ if facts.source_chain_id == ETHEREUM_CHAIN_ID
1132
+ router = source_swap["routerAddress"]
1133
+ fail_bridge(code) unless source_swap["routerKind"] == "provider-selected-evm" && router.is_a?(String) && EVM_ADDRESS.match?(router) && router.downcase != "0x#{'0' * 40}"
1134
+ normalized_router = router.downcase
1135
+ router_kind = "provider-selected-evm"
1136
+ else
1137
+ fail_bridge(code) unless source_swap["routerKind"] == "jupiter-v6" && source_swap["routerAddress"] == SOLANA_JUPITER_V6
1138
+ normalized_router = SOLANA_JUPITER_V6
1139
+ router_kind = "jupiter-v6"
1140
+ end
1141
+ dependencies = facts.source_chain_id == SOLANA_CHAIN_ID ? BASE_DEPENDENCIES + ["jupiter-v6-source-swap"] : BASE_DEPENDENCIES
1142
+ fail_bridge(code) unless source_swap.is_a?(Hash) && quote["dependencies"].is_a?(Array) && strict_equal?(quote["dependencies"], dependencies)
1143
+ raw = require_string(quote["rawSignedQuoteJson"], code)
1144
+ fail_bridge(code) if raw.bytesize > MAX_RAW_QUOTE_BYTES
1145
+ {
1146
+ "quoteKind" => "mayan-swift-v2", "providerId" => "mayan-swift-v2",
1147
+ "sourceChainId" => source_chain, "destinationChainId" => destination_chain,
1148
+ "sourceTokenDeploymentId" => facts.source_token_deployment_id,
1149
+ "destinationTokenDeploymentId" => facts.destination_token_deployment_id,
1150
+ "amountIn" => amount.first, "expectedAmountOut" => expected_amount.first,
1151
+ "minimumAmountOut" => minimum_amount.first, "minimumReceived" => minimum_received.first,
1152
+ "deadline" => deadline.first, "slippageBps" => slippage,
1153
+ "quoteId" => quote_id.downcase, "providerSignature" => signature.downcase,
1154
+ "sourceSwap" => {
1155
+ "required" => true, "inputTokenDeploymentId" => facts.source_token_deployment_id,
1156
+ "intermediateTokenDeploymentId" => facts.source_usdc_deployment_id,
1157
+ "intermediateTokenAddress" => facts.source_usdc_address,
1158
+ "intermediateTokenStandard" => provider_standard, "intermediateTokenDecimals" => 6,
1159
+ "providerMinimumAmount" => provider_minimum, "routerKind" => router_kind,
1160
+ "routerAddress" => normalized_router
1161
+ },
1162
+ "dependencies" => dependencies,
1163
+ "quoteVerification" => "provider-signed-not-locally-verified", "rawSignedQuoteJson" => raw
1164
+ }
1165
+ rescue BridgeError => error
1166
+ ERPC.raise_safe_bridge_error(error)
1167
+ rescue StandardError
1168
+ fail_bridge(code)
1169
+ end
1170
+
1171
+ def validate_raw_quote_for_build(quote, route)
1172
+ raw = quote.fetch("rawSignedQuoteJson")
1173
+ root = parse_provider_response(raw)
1174
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH) unless root.object_entries && root.start.zero? && root.end == raw.length
1175
+ rebuilt = begin
1176
+ validate_provider_quote(root, raw, route.request, route.facts)
1177
+ rescue BridgeError => error
1178
+ ERPC.raise_safe_bridge_error(error) if error.code == BridgeErrorCode::QUOTE_EXPIRED
1179
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH)
1180
+ end
1181
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH) unless strict_equal?(rebuilt, quote)
1182
+ deep_dup(quote)
1183
+ rescue BridgeError => error
1184
+ ERPC.raise_safe_bridge_error(error) if error.code == BridgeErrorCode::QUOTE_MISMATCH || error.code == BridgeErrorCode::QUOTE_EXPIRED
1185
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH)
1186
+ rescue StandardError
1187
+ fail_bridge(BridgeErrorCode::QUOTE_MISMATCH)
1188
+ end
1189
+
1190
+ def numeric_zero?(value)
1191
+ return false if value == true || value == false
1192
+ return value.finite? && value.zero? if value.is_a?(Numeric)
1193
+ value.is_a?(String) && (value == "0" || value.match?(/\A0x0+\z/i))
1194
+ end
1195
+
1196
+ def validate_evm_build_result(wrapper, swapper_address)
1197
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(wrapper, "chainCategory") == "evm" && provider_string(wrapper, "quoteType") == "SWIFT"
1198
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if provider_boolean(wrapper, "gasless")
1199
+ transaction = required_node(wrapper, "transaction")
1200
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless transaction.object_entries && transaction.value.is_a?(Hash)
1201
+ to = provider_string(transaction, "to")
1202
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_address_equals?(to, ETHEREUM_FORWARDER)
1203
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_integer(transaction, "chainId") == 1
1204
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless numeric_zero?(object_value(transaction, "value"))
1205
+ data = provider_string(transaction, "data").downcase
1206
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless HEX_BYTES.match?(data) && data.length.even? && data.start_with?(ETHEREUM_FORWARDER_SELECTOR) && data.length >= 2 + 8 + 13 * 64
1207
+ {
1208
+ "kind" => "evm-unsigned-transaction", "chainId" => ETHEREUM_CHAIN_ID,
1209
+ "from" => swapper_address, "to" => ETHEREUM_FORWARDER, "data" => data, "value" => "0"
1210
+ }
1211
+ end
1212
+
1213
+ def base58_decode(value, expected_bytes, code)
1214
+ text = require_string(value, code)
1215
+ fail_bridge(code) if text.empty? || text.length > expected_bytes * 2 || text.each_char.any? { |char| !BASE58_INDEX.key?(char) }
1216
+ number = 0
1217
+ text.each_char { |char| number = number * 58 + BASE58_INDEX.fetch(char) }
1218
+ if number.zero?
1219
+ raw = ""
1220
+ else
1221
+ hex = number.to_s(16)
1222
+ hex = "0#{hex}" if hex.length.odd?
1223
+ raw = [hex].pack("H*")
1224
+ end
1225
+ leading_zeroes = text[/\A1*/].to_s.length
1226
+ result = ("\0" * leading_zeroes) + raw
1227
+ fail_bridge(code) unless result.bytesize == expected_bytes && base58_encode(result) == text
1228
+ result
1229
+ rescue ArgumentError
1230
+ fail_bridge(code)
1231
+ end
1232
+
1233
+ def base58_encode(bytes)
1234
+ leading_zeroes = bytes.bytes.take_while(&:zero?).length
1235
+ number = bytes.empty? ? 0 : bytes.unpack1("H*").to_i(16)
1236
+ return "1" * leading_zeroes if number.zero?
1237
+ chars = []
1238
+ while number.positive?
1239
+ number, remainder = number.divmod(58)
1240
+ chars << BASE58_ALPHABET[remainder]
1241
+ end
1242
+ ("1" * leading_zeroes) + chars.reverse.join
1243
+ end
1244
+
1245
+ def decode_base64(value, code)
1246
+ text = require_string(value, code)
1247
+ fail_bridge(code) unless BASE64.match?(text)
1248
+ begin
1249
+ decoded = Base64.strict_decode64(text)
1250
+ rescue ArgumentError
1251
+ fail_bridge(code)
1252
+ end
1253
+ fail_bridge(code) unless Base64.strict_encode64(decoded) == text
1254
+ decoded
1255
+ end
1256
+
1257
+ def read_bytes(bytes, cursor, count, code)
1258
+ fail_bridge(code) unless count.is_a?(Integer) && count >= 0 && cursor + count <= bytes.bytesize
1259
+ value = bytes.byteslice(cursor, count)
1260
+ [value, cursor + count]
1261
+ end
1262
+
1263
+ def read_short_vec(bytes, cursor, maximum, code)
1264
+ value = 0
1265
+ shift = 0
1266
+ 5.times do |count|
1267
+ byte_text, cursor = read_bytes(bytes, cursor, 1, code)
1268
+ byte = byte_text.getbyte(0)
1269
+ payload = byte & 0x7f
1270
+ fail_bridge(code) if shift >= 28 || payload > MAX_SAFE_INTEGER / (1 << shift)
1271
+ value += payload << shift
1272
+ unless (byte & 0x80).positive?
1273
+ fail_bridge(code) if count.positive? && payload.zero?
1274
+ fail_bridge(code) if value > maximum
1275
+ return [value, cursor]
1276
+ end
1277
+ shift += 7
1278
+ end
1279
+ fail_bridge(code)
1280
+ end
1281
+
1282
+ def validate_solana_transaction(value, fee_payer)
1283
+ encoded = require_string(value, BridgeErrorCode::BUILD_INVALID)
1284
+ bytes = decode_base64(encoded, BridgeErrorCode::BUILD_INVALID)
1285
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) if bytes.empty? || bytes.bytesize > 1232
1286
+ cursor = 0
1287
+ signature_count, cursor = read_short_vec(bytes, cursor, 1, BridgeErrorCode::BUILD_INVALID)
1288
+ signatures, cursor = read_bytes(bytes, cursor, 64, BridgeErrorCode::BUILD_INVALID)
1289
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless signature_count == 1 && signatures.bytes.all?(&:zero?)
1290
+ version, cursor = read_bytes(bytes, cursor, 1, BridgeErrorCode::BUILD_INVALID)
1291
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless version.getbyte(0) == 0x80
1292
+ required, cursor = read_bytes(bytes, cursor, 1, BridgeErrorCode::BUILD_INVALID)
1293
+ readonly_signed, cursor = read_bytes(bytes, cursor, 1, BridgeErrorCode::BUILD_INVALID)
1294
+ readonly_unsigned, cursor = read_bytes(bytes, cursor, 1, BridgeErrorCode::BUILD_INVALID)
1295
+ required_value = required.getbyte(0); readonly_signed_value = readonly_signed.getbyte(0); readonly_unsigned_value = readonly_unsigned.getbyte(0)
1296
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless required_value == 1 && readonly_signed_value.zero?
1297
+ static_key_count, cursor = read_short_vec(bytes, cursor, 64, BridgeErrorCode::BUILD_INVALID)
1298
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) if static_key_count.zero? || readonly_unsigned_value >= static_key_count
1299
+ static_keys, cursor = read_bytes(bytes, cursor, static_key_count * 32, BridgeErrorCode::BUILD_INVALID)
1300
+ payer = base58_decode(fee_payer, 32, BridgeErrorCode::BUILD_INVALID)
1301
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless static_keys.byteslice(0, 32) == payer
1302
+ _blockhash, cursor = read_bytes(bytes, cursor, 32, BridgeErrorCode::BUILD_INVALID)
1303
+ instruction_count, cursor = read_short_vec(bytes, cursor, 64, BridgeErrorCode::BUILD_INVALID)
1304
+ largest = -1
1305
+ instruction_count.times do
1306
+ program, cursor = read_bytes(bytes, cursor, 1, BridgeErrorCode::BUILD_INVALID)
1307
+ largest = [largest, program.getbyte(0)].max
1308
+ account_count, cursor = read_short_vec(bytes, cursor, 64, BridgeErrorCode::BUILD_INVALID)
1309
+ accounts, cursor = read_bytes(bytes, cursor, account_count, BridgeErrorCode::BUILD_INVALID)
1310
+ largest = [largest, accounts.bytes.max || -1].max
1311
+ data_length, cursor = read_short_vec(bytes, cursor, 1024, BridgeErrorCode::BUILD_INVALID)
1312
+ _data, cursor = read_bytes(bytes, cursor, data_length, BridgeErrorCode::BUILD_INVALID)
1313
+ end
1314
+ lookup_count, cursor = read_short_vec(bytes, cursor, 32, BridgeErrorCode::BUILD_INVALID)
1315
+ loaded = 0
1316
+ lookup_count.times do
1317
+ _lookup, cursor = read_bytes(bytes, cursor, 32, BridgeErrorCode::BUILD_INVALID)
1318
+ writable_count, cursor = read_short_vec(bytes, cursor, 64, BridgeErrorCode::BUILD_INVALID)
1319
+ _writable, cursor = read_bytes(bytes, cursor, writable_count, BridgeErrorCode::BUILD_INVALID)
1320
+ readonly_count, cursor = read_short_vec(bytes, cursor, 64, BridgeErrorCode::BUILD_INVALID)
1321
+ _readonly, cursor = read_bytes(bytes, cursor, readonly_count, BridgeErrorCode::BUILD_INVALID)
1322
+ loaded += writable_count + readonly_count
1323
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) if loaded > 256
1324
+ end
1325
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) if largest >= static_key_count + loaded || cursor != bytes.bytesize
1326
+ encoded
1327
+ end
1328
+
1329
+ def validate_solana_build_result(wrapper, swapper_address)
1330
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_string(wrapper, "chainCategory") == "svm" && provider_string(wrapper, "quoteType") == "SWIFT"
1331
+ gasless = object_value(wrapper, "gasless")
1332
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless gasless == :__bridge_missing__ || gasless == false
1333
+ encoded = validate_solana_transaction(required_node(wrapper, "transaction").value, swapper_address)
1334
+ { "kind" => "solana-v0-unsigned-transaction", "chainId" => SOLANA_CHAIN_ID, "feePayer" => swapper_address, "transactionBase64" => encoded }
1335
+ end
1336
+
1337
+ def validate_build_response(root, raw_text, quote, facts, swapper_address)
1338
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless root.object_entries && root.value.is_a?(Hash) && object_value(root, "success") == true
1339
+ wrapper = required_node(root, "transaction")
1340
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless wrapper.object_entries && wrapper.value.is_a?(Hash)
1341
+ signers = object_value(wrapper, "signers")
1342
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless signers == :__bridge_missing__ || signers.nil? || (signers.is_a?(Array) && signers.empty?)
1343
+ swap_message = object_value(wrapper, "swapMessageV0Params")
1344
+ fail_bridge(BridgeErrorCode::BUILD_INVALID) unless swap_message == :__bridge_missing__ || swap_message.nil?
1345
+ transaction = begin
1346
+ facts.source_chain_id == ETHEREUM_CHAIN_ID ? validate_evm_build_result(wrapper, swapper_address) : validate_solana_build_result(wrapper, swapper_address)
1347
+ rescue BridgeError => error
1348
+ ERPC.raise_safe_bridge_error(error) if error.code == BridgeErrorCode::BUILD_INVALID
1349
+ fail_bridge(BridgeErrorCode::BUILD_INVALID)
1350
+ end
1351
+ {
1352
+ "buildKind" => "mayan-swift-v2-unsigned", "providerId" => "mayan-swift-v2",
1353
+ "quote" => deep_dup(quote), "sourceChainId" => quote.fetch("sourceChainId"),
1354
+ "destinationChainId" => quote.fetch("destinationChainId"), "transaction" => transaction,
1355
+ "allowance" => facts.source_chain_id == ETHEREUM_CHAIN_ID ? {
1356
+ "tokenDeploymentId" => ETHEREUM_EURC_DEPLOYMENT_ID, "tokenAddress" => ETHEREUM_EURC_ADDRESS,
1357
+ "owner" => swapper_address, "spender" => ETHEREUM_FORWARDER, "requiredAmount" => quote.fetch("amountIn")
1358
+ } : nil,
1359
+ "validation" => {
1360
+ "level" => "structural", "quoteSignatureLocallyVerified" => false,
1361
+ "transactionSemanticsLocallyVerified" => false, "settlementLocallyVerified" => false
1362
+ },
1363
+ "rawProviderBuildJson" => raw_text
1364
+ }
1365
+ end
1366
+
1367
+ def normalize_status_request(value)
1368
+ request = require_record(deep_dup(value), BridgeErrorCode::INVALID_ARGUMENT)
1369
+ exact_keys(request, %w[sourceChainId sourceTransactionHash], BridgeErrorCode::INVALID_ARGUMENT)
1370
+ chain_id = require_string(request["sourceChainId"], BridgeErrorCode::INVALID_ARGUMENT)
1371
+ tx_hash = require_string(request["sourceTransactionHash"], BridgeErrorCode::INVALID_ARGUMENT)
1372
+ if chain_id == ETHEREUM_CHAIN_ID
1373
+ fail_bridge(BridgeErrorCode::INVALID_ARGUMENT) unless EVM_HASH.match?(tx_hash)
1374
+ return { "sourceChainId" => chain_id, "sourceTransactionHash" => tx_hash.downcase }
1375
+ end
1376
+ if chain_id == SOLANA_CHAIN_ID
1377
+ base58_decode(tx_hash, 64, BridgeErrorCode::INVALID_ARGUMENT)
1378
+ return { "sourceChainId" => chain_id, "sourceTransactionHash" => tx_hash }
1379
+ end
1380
+ fail_bridge(BridgeErrorCode::UNSUPPORTED_ROUTE)
1381
+ end
1382
+
1383
+ def status_from_response(root, raw_text, request)
1384
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless root.object_entries && root.value.is_a?(Hash)
1385
+ client_status = provider_string(root, "clientStatus")
1386
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if client_status.length > 128
1387
+ provider_status = object_value(root, "status")
1388
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless provider_status == :__bridge_missing__ || provider_status.nil? || (provider_status.is_a?(String) && provider_status.length <= 1024)
1389
+ provider_status = nil if provider_status == :__bridge_missing__
1390
+ state = { "INPROGRESS" => "in-progress", "COMPLETED" => "completed", "REFUNDED" => "refunded" }.fetch(client_status, "unknown")
1391
+ {
1392
+ "statusKind" => "mayan-explorer-index", "providerId" => "mayan-swift-v2",
1393
+ "sourceChainId" => request.fetch("sourceChainId"), "sourceTransactionHash" => request.fetch("sourceTransactionHash"),
1394
+ "state" => state, "providerClientStatus" => client_status, "providerStatus" => provider_status,
1395
+ "statusVerification" => "provider-indexed-not-locally-verified", "rawProviderStatusJson" => raw_text
1396
+ }
1397
+ end
1398
+
1399
+ def endpoint_with_path(base, path)
1400
+ uri = URI.parse(base)
1401
+ base_path = uri.path.to_s.sub(%r{/+\z}, "")
1402
+ uri.path = "#{base_path}/#{path.sub(%r{\A/+}, "")}"
1403
+ uri.query = nil
1404
+ uri.fragment = nil
1405
+ uri.to_s
1406
+ rescue URI::InvalidURIError, ArgumentError
1407
+ fail_bridge(BridgeErrorCode::PROVIDER_TRANSPORT)
1408
+ end
1409
+
1410
+ def provider_request(url, method, body, include_builder_key:, operation:, options:)
1411
+ check_aborted(options)
1412
+ headers = { "accept" => "application/json" }
1413
+ headers["content-type"] = "application/json" unless body.nil?
1414
+ headers["x-api-key"] = @config.builder_api_key if include_builder_key && @config.builder_api_key
1415
+ begin
1416
+ response = @http_adapter.request(method: method, url: url, headers: headers, body: body, timeout: @config.timeout)
1417
+ rescue BridgeError => error
1418
+ ERPC.raise_safe_bridge_error(error)
1419
+ rescue MayanBridgeBodyTooLarge
1420
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE)
1421
+ rescue TimeoutError, Timeout::Error
1422
+ fail_bridge(BridgeErrorCode::TIMEOUT)
1423
+ rescue StandardError
1424
+ check_aborted(options)
1425
+ fail_bridge(BridgeErrorCode::PROVIDER_TRANSPORT)
1426
+ end
1427
+ check_aborted(options)
1428
+ status = if response.respond_to?(:status)
1429
+ response.status
1430
+ elsif response.is_a?(Hash)
1431
+ response[:status] || response["status"]
1432
+ end
1433
+ fail_bridge(BridgeErrorCode::PROVIDER_TRANSPORT) unless status.is_a?(Integer)
1434
+ if operation == :status && status == 404
1435
+ fail_bridge(BridgeErrorCode::STATUS_NOT_FOUND)
1436
+ elsif status.between?(300, 399)
1437
+ fail_bridge(BridgeErrorCode::PROVIDER_TRANSPORT)
1438
+ elsif operation == :build && [401, 403].include?(status)
1439
+ fail_bridge(BridgeErrorCode::PROVIDER_AUTH_REQUIRED)
1440
+ elsif ![200, 201].include?(status)
1441
+ fail_bridge(BridgeErrorCode::PROVIDER_HTTP, status)
1442
+ end
1443
+ body_value = if response.respond_to?(:body)
1444
+ response.body
1445
+ elsif response.is_a?(Hash)
1446
+ response[:body] || response["body"]
1447
+ end
1448
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless body_value.is_a?(String)
1449
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if body_value.bytesize > MAX_RESPONSE_BYTES
1450
+ body_value = body_value.dup.force_encoding(Encoding::UTF_8)
1451
+ fail_bridge(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) unless body_value.valid_encoding?
1452
+ body_value
1453
+ end
1454
+
1455
+ def check_aborted(options)
1456
+ cancelled = if options.is_a?(Hash)
1457
+ options[:cancelled] || options["cancelled"] || options[:aborted] || options["aborted"]
1458
+ elsif options.respond_to?(:cancelled?)
1459
+ options.cancelled?
1460
+ elsif options.respond_to?(:aborted?)
1461
+ options.aborted?
1462
+ end
1463
+ fail_bridge(BridgeErrorCode::ABORTED) if cancelled
1464
+ end
1465
+
1466
+ def strict_equal?(left, right)
1467
+ return false unless left.class == right.class
1468
+ case left
1469
+ when Hash
1470
+ left.keys.sort == right.keys.sort && left.all? { |key, value| strict_equal?(value, right[key]) }
1471
+ when Array
1472
+ left.length == right.length && left.each_index.all? { |index| strict_equal?(left[index], right[index]) }
1473
+ else
1474
+ left == right
1475
+ end
1476
+ end
1477
+
1478
+ def deep_dup(value)
1479
+ case value
1480
+ when Hash
1481
+ value.each_with_object({}) { |(key, child), copy| copy[deep_dup(key)] = deep_dup(child) }
1482
+ when Array
1483
+ value.map { |child| deep_dup(child) }
1484
+ when String
1485
+ value.dup
1486
+ else
1487
+ value
1488
+ end
1489
+ end
1490
+
1491
+ def deep_freeze(value)
1492
+ case value
1493
+ when Hash
1494
+ value.each { |key, child| deep_freeze(key); deep_freeze(child) }
1495
+ when Array
1496
+ value.each { |child| deep_freeze(child) }
1497
+ end
1498
+ value.freeze
1499
+ end
1500
+ end
1501
+
1502
+ BridgeClient = MayanSwiftV2BridgeClient
1503
+
1504
+ def self.create_mayan_swift_v2_bridge_client(config = nil, http_adapter: nil, adapter: nil, **config_keywords)
1505
+ MayanSwiftV2BridgeClient.new(config, http_adapter: http_adapter, adapter: adapter, **config_keywords)
1506
+ end
1507
+ end