erpc-sdk 0.8.0 → 0.9.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,1836 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Local, unsigned construction for the Mayan Swift v2 bridge. This file is
4
+ # intentionally additive: the hosted quote/build/status path remains in
5
+ # bridge.rb and local construction never calls the hosted /build endpoint.
6
+
7
+ require "base64"
8
+ require "digest"
9
+ require "digest/keccak"
10
+ require "json"
11
+ require "thread"
12
+ require "uri"
13
+ require_relative "bridge"
14
+
15
+ module ERPC
16
+ module MayanSwiftV2BridgeLocal
17
+ module_function
18
+
19
+ ETHEREUM_FORWARDER_PROVIDER = "0x337685fdaB40D39bd02028545a4FfA7D287cC3E2"
20
+ SOLANA_SWIFT_PROGRAM = "mayan34VedncxdK2XobtvWFDXQASUTBXhUVzt2kKgny"
21
+ SOLANA_JUPITER_V6 = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"
22
+ SOLANA_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
23
+ SOLANA_ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
24
+ SOLANA_SYSTEM_PROGRAM = "11111111111111111111111111111111"
25
+ SOLANA_SYSVAR_RENT = "SysvarRent111111111111111111111111111111111"
26
+ SOLANA_COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111"
27
+ SOLANA_CPI_PROXY_PROGRAM = "D8C8iW6zmoKg5TRr8nQ7h14TMWqQX8FiBdj2ju5MF3wa"
28
+ SOLANA_ANCHOR_EVENT_AUTHORITY = "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf"
29
+ SOLANA_FEE_MANAGER_PROGRAM = "5VtQHnhs2pfVEr68qQsbTRwKh4JV5GTu9mBHgHFxpHeQ"
30
+ SOLANA_MAINNET_GENESIS_HASH = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
31
+ SOLANA_MAYAN_LOOKUP_TABLE = "Ff3yi1meWQQ19VPZMzGg6H8JQQeRudiV7QtVtyzJyoht"
32
+ SOLANA_ADDRESS_LOOKUP_TABLE_OWNER = "AddressLookupTab1e1111111111111111111111111"
33
+ SOLANA_ROUTE_V2_DISCRIMINATOR = "bb64facc31c4af14"
34
+ SOLANA_ROUTE_V2_WHIRLPOOL_TAIL = "000001000000110010270001"
35
+ SOLANA_ROUTE_V2_RAYDIUM_TAIL = "0000010000001a10270001"
36
+ SOLANA_WHIRLPOOL_PROGRAM = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"
37
+ SOLANA_WHIRLPOOL_POOL = "ArisQNcbjXPJD7RgPRvysatX3xcfHPTbcTkfD8kDoZ9i"
38
+ SOLANA_RAYDIUM_CLMM_PROGRAM = "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"
39
+ SOLANA_RAYDIUM_CLMM_POOL = "2zVV22uNWdJNmkXpj5vCrMzwHGBoJdsyV7qACh29sK1w"
40
+ SOLANA_WHIRLPOOL_TICK_ARRAY_0 = "6i68TM44UYSawGAS4Bx1vX31Af7QNZaRNBLUbc4r8exB"
41
+ SOLANA_WHIRLPOOL_TICK_ARRAY_1 = "8aq9zUXe37KLtXSaEYt7oq65oNAJiu1my2kRNMRPhTD5"
42
+ SOLANA_WHIRLPOOL_TICK_ARRAY_2 = "7qscKXFXCd1WQinZJvSDLsGLTTEwjmd88a871pz2V3Ja"
43
+ SOLANA_WHIRLPOOL_ORACLE = "CaohZGaBaLmXyFQ4cLc83wGZTET7Mt9xMtUqR9EGaMHF"
44
+ SOLANA_WHIRLPOOL_REWARD_VAULT = "7Mr4WYMiGPAXkyt9ePsHHmV6ust3U6dHwBmyfMRiHPA7"
45
+ SOLANA_WHIRLPOOL_REMAINING = "9BjNZYSCZ3ac3XUYVKte4YYtmBGd7ATKfRshTGN99NxQ"
46
+ SOLANA_RAYDIUM_CONFIG = "9iFER3bpjf1PTTCQCfTRu17EJgvsxo9pVyA9QWwEuX4x"
47
+ SOLANA_RAYDIUM_OBSERVATION = "GFwsANMCPK8W3WhqTnwVP8JiwHaAr3cNDe5TJqgHHSPe"
48
+ SOLANA_RAYDIUM_TICK_ARRAY_0 = "ECw2X1TYbsrqFgdiYApNpn2ggznbj8pL5tREpY9Fb8jY"
49
+ SOLANA_RAYDIUM_TICK_ARRAY_1 = "2UQncszfVU7igwDiGN3jq2sUKzziLxDZqNsJEXzEob5x"
50
+ SOLANA_RAYDIUM_TICK_ARRAY_2 = "BVvv13QAQjPYKTWrpX7wQbhBAu3pbWwTb8P9SAqgRNKQ"
51
+ SOLANA_RAYDIUM_TICK_ARRAY_3 = "4HSR9WBSHgw7n5V8WGYYhLW8g92RPSrgnf2CHzbeQrrr"
52
+ SOLANA_RAYDIUM_ORACLE = "HssFpWsQcNbJXBro1NVWCFAYEh8jp6NJV2nyFhE1zMGj"
53
+ SOLANA_RAYDIUM_REMAINING = "DKcmVcrXuiF5FZre6h8GqurR2sKChUGBakxdTX7dSDw9"
54
+ SOLANA_INIT_ORDER_DISCRIMINATOR = "204c290c27a284db"
55
+ EVM_SOURCE_SWAP_SELECTOR = "0x3f0bde25"
56
+ SOLANA_MAYAN_SOURCE_SWAP_ENDPOINT = "https://price-api.mayan.finance/v3"
57
+ MAYAN_REFERENCE_COMMIT = "c4c98031aaad9264d17630d7b4de0cb18688cf78"
58
+ MAYAN_ORACLE_SDK_VERSION = "15_2_2"
59
+
60
+ MAX_RESPONSE_BYTES = 1024 * 1024
61
+ MAX_JSON_DEPTH = 32
62
+ MAX_ROUTER_CALLDATA_BYTES = 16_384
63
+ MAX_SOLANA_SWAP_ACCOUNTS = 64
64
+ MAX_SOLANA_SWAP_DATA_BYTES = 4096
65
+ MAX_LOOKUP_TABLES = 8
66
+ MAX_LOOKUP_TABLE_ADDRESSES = 256
67
+ MAX_SOLANA_TRANSACTION_BYTES = 1232
68
+ UINT64_MAX = (1 << 64) - 1
69
+ MAX_SAFE_INTEGER = (1 << 53) - 1
70
+ RESPONSE_DISPOSAL_TIMEOUT = 0.05
71
+ STREAM_QUEUE_CAPACITY = 8
72
+ EVM_ADDRESS = /\A0x[0-9a-fA-F]{40}\z/.freeze
73
+ HEX_BYTES = /\A0x(?:[0-9a-fA-F]{2})*\z/.freeze
74
+ UINT64 = /\A(?:0|[1-9][0-9]*)\z/.freeze
75
+ NONCE = /\A0x[0-9a-f]{32}\z/.freeze
76
+ PLAIN_DECIMAL = /\A(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/.freeze
77
+ BASE64 = /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/.freeze
78
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
79
+ BASE58_INDEX = BASE58_ALPHABET.each_char.with_index.to_h.freeze
80
+
81
+ LocalCapability = Struct.new(
82
+ :row, :is_usdc,
83
+ keyword_init: true
84
+ ) do
85
+ def capability_id = row.fetch("bridgeCapabilityId")
86
+ def source_chain_id = row.fetch("sourceChainId")
87
+ def destination_chain_id = row.fetch("destinationChainId")
88
+ def source_token_deployment_id = row.fetch("sourceTokenDeploymentId")
89
+ def destination_token_deployment_id = row.fetch("destinationTokenDeploymentId")
90
+ def source_token_address = row.fetch("sourceTokenAddress")
91
+ def destination_token_address = row.fetch("destinationTokenAddress")
92
+ def source_usdc_address = row.fetch("sourceUsdcAddress")
93
+ def source_usdc_deployment_id = row.fetch("sourceUsdcDeploymentId")
94
+ def source_name = row.fetch("sourceProviderChainName")
95
+ def destination_name = row.fetch("destinationProviderChainName")
96
+ def source_provider_chain_id = row.fetch("sourceProviderChainId")
97
+ def destination_provider_chain_id = row.fetch("destinationProviderChainId")
98
+ def source_wormhole_chain_id = row.fetch("sourceWormholeChainId")
99
+ def destination_wormhole_chain_id = row.fetch("destinationWormholeChainId")
100
+ def source_token_standard = row.fetch("sourceTokenStandard")
101
+ def destination_token_standard = row.fetch("destinationTokenStandard")
102
+ def source_usdc_standard = row.fetch("sourceUsdcStandard")
103
+ def swift_contract = row.fetch("swiftContract")
104
+ def dependencies = row.fetch("dependencies")
105
+ end
106
+
107
+ ParsedSourceQuote = Struct.new(
108
+ :raw, :root, :minimum_intermediate_amount, :mode,
109
+ :cancel_fee, :refund_fee, :submit_fee, :suggested_priority_fee,
110
+ keyword_init: true
111
+ )
112
+ LookupTable = Struct.new(:address, :addresses, :data, keyword_init: true)
113
+ SourceRpcResult = Struct.new(:evidence, :lookup_tables, :recent_blockhash, keyword_init: true)
114
+ KeyMeta = Struct.new(:signer, :writable, :invoked, keyword_init: true)
115
+ LocalValidationError = Class.new(StandardError)
116
+ LocalResponseTooLarge = Class.new(StandardError)
117
+ LocalResponseInvalidEncoding = Class.new(StandardError)
118
+
119
+ def local_invalid!
120
+ raise LocalValidationError
121
+ end
122
+
123
+ def fail_local(code, status = nil)
124
+ raise BridgeError.new(code, status), cause: nil
125
+ end
126
+
127
+ def concat(*parts)
128
+ parts.join.b
129
+ end
130
+
131
+ def sha256_hex(value)
132
+ Digest::SHA256.hexdigest(value.is_a?(String) ? value.b : value)
133
+ end
134
+
135
+ def keccak_hex(value)
136
+ Digest::Keccak.hexdigest(value.b, 256)
137
+ end
138
+
139
+ def stable_json(value)
140
+ case value
141
+ when Hash
142
+ entries = value.map { |key, child| [key.to_s, child] }.sort_by(&:first)
143
+ "{" + entries.map { |key, child| "#{JSON.generate(key)}:#{stable_json(child)}" }.join(",") + "}"
144
+ when Array
145
+ "[#{value.map { |child| stable_json(child) }.join(",")}]"
146
+ when String
147
+ JSON.generate(value)
148
+ when Integer, Float, TrueClass, FalseClass, NilClass
149
+ JSON.generate(value, allow_nan: false)
150
+ else
151
+ local_invalid!
152
+ end
153
+ rescue JSON::GeneratorError, TypeError
154
+ local_invalid!
155
+ end
156
+
157
+ def deep_dup(value)
158
+ case value
159
+ when Hash
160
+ value.each_with_object({}) { |(key, child), copy| copy[deep_dup(key)] = deep_dup(child) }
161
+ when Array
162
+ value.map { |child| deep_dup(child) }
163
+ when String
164
+ value.dup
165
+ else
166
+ value
167
+ end
168
+ end
169
+
170
+ def deep_freeze(value)
171
+ case value
172
+ when Hash
173
+ value.each { |key, child| deep_freeze(key); deep_freeze(child) }
174
+ when Array
175
+ value.each { |child| deep_freeze(child) }
176
+ end
177
+ value.freeze
178
+ end
179
+
180
+ def hex_to_bytes(value)
181
+ local_invalid! unless value.is_a?(String)
182
+ source = value.start_with?("0x") ? value[2..] : value
183
+ local_invalid! unless source.length.even? && source.match?(/\A[0-9a-fA-F]*\z/)
184
+ [source].pack("H*")
185
+ rescue ArgumentError
186
+ local_invalid!
187
+ end
188
+
189
+ def normalize_evm_address(value)
190
+ local_invalid! unless value.is_a?(String) && EVM_ADDRESS.match?(value)
191
+ local_invalid! if value.downcase == "0x#{'0' * 40}"
192
+ value.downcase
193
+ end
194
+
195
+ def base58_decode(value)
196
+ local_invalid! unless value.is_a?(String) && !value.empty?
197
+ local_invalid! unless value.each_char.all? { |char| BASE58_INDEX.key?(char) }
198
+ number = 0
199
+ value.each_char { |char| number = number * 58 + BASE58_INDEX.fetch(char) }
200
+ raw = if number.zero?
201
+ "".b
202
+ else
203
+ hex = number.to_s(16)
204
+ hex = "0#{hex}" if hex.length.odd?
205
+ [hex].pack("H*")
206
+ end
207
+ leading = value[/\A1*/].to_s.length
208
+ ("\0".b * leading) + raw
209
+ rescue ArgumentError
210
+ local_invalid!
211
+ end
212
+
213
+ def base58_encode(bytes)
214
+ local_invalid! unless bytes.is_a?(String)
215
+ number = bytes.empty? ? 0 : bytes.unpack1("H*").to_i(16)
216
+ result = +""
217
+ while number.positive?
218
+ number, remainder = number.divmod(58)
219
+ result << BASE58_ALPHABET[remainder]
220
+ end
221
+ leading = bytes.bytes.take_while(&:zero?).length
222
+ ("1" * leading) + result.reverse
223
+ end
224
+
225
+ def canonical_solana_address(value)
226
+ local_invalid! unless value.is_a?(String)
227
+ bytes = base58_decode(value)
228
+ local_invalid! unless bytes.bytesize == 32 && base58_encode(bytes) == value
229
+ value
230
+ end
231
+
232
+ def uint64(value, positive: false)
233
+ local_invalid! unless value.is_a?(String) && value.length <= 20 && UINT64.match?(value)
234
+ parsed = Integer(value, 10)
235
+ local_invalid! if parsed > UINT64_MAX || (positive && parsed.zero?)
236
+ parsed
237
+ rescue ArgumentError
238
+ local_invalid!
239
+ end
240
+
241
+ def word_uint(value)
242
+ local_invalid! unless value.is_a?(Integer) && value >= 0 && value < (1 << 256)
243
+ [value].pack("H*") if false
244
+ hex = value.to_s(16).rjust(64, "0")
245
+ [hex].pack("H*")
246
+ end
247
+
248
+ def word_address(value)
249
+ "\0".b * 12 + hex_to_bytes(normalize_evm_address(value))
250
+ end
251
+
252
+ def word_bytes32(value)
253
+ local_invalid! unless value.is_a?(String) && value.bytesize == 32
254
+ value.b
255
+ end
256
+
257
+ def pad32(value)
258
+ value.b + ("\0".b * ((-value.bytesize) % 32))
259
+ end
260
+
261
+ def encode_abi_bytes(value)
262
+ word_uint(value.bytesize) + pad32(value)
263
+ end
264
+
265
+ def encode_abi_with_dynamics(head, dynamics)
266
+ offset = (head.length + dynamics.length) * 32
267
+ offsets = []
268
+ dynamics.each do |value|
269
+ offsets << word_uint(offset)
270
+ offset += 32 + pad32(value).bytesize
271
+ end
272
+ head.join.b + offsets.join.b + dynamics.map { |value| encode_abi_bytes(value) }.join.b
273
+ end
274
+
275
+ def native_address_bytes(value, chain_id)
276
+ return word_address(value) if chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID
277
+
278
+ canonical_solana_address(value)
279
+ base58_decode(value)
280
+ end
281
+
282
+ def capability_records
283
+ parsed = JSON.parse(BRIDGE_CAPABILITIES_JSON)
284
+ parsed.is_a?(Array) ? parsed : []
285
+ rescue JSON::ParserError
286
+ []
287
+ end
288
+
289
+ def route_for_quote(quote)
290
+ local_invalid! unless quote.is_a?(Hash)
291
+ source_chain = quote["sourceChainId"]
292
+ destination_chain = quote["destinationChainId"]
293
+ source_token = quote["sourceTokenDeploymentId"]
294
+ destination_token = quote["destinationTokenDeploymentId"]
295
+ local_invalid! unless [source_chain, destination_chain, source_token, destination_token].all? { |value| value.is_a?(String) }
296
+ row = capability_records.find do |entry|
297
+ entry.is_a?(Hash) && entry["sourceChainId"] == source_chain &&
298
+ entry["destinationChainId"] == destination_chain &&
299
+ entry["sourceTokenDeploymentId"] == source_token &&
300
+ entry["destinationTokenDeploymentId"] == destination_token
301
+ end
302
+ local_invalid! unless row
303
+ LocalCapability.new(row: row, is_usdc: row.fetch("bridgeCapabilityId").include?("-usdc-"))
304
+ end
305
+
306
+ def assert_catalog_token(deployment_id, chain_id, address, standard)
307
+ token = TokenCatalog.get_token_deployment(deployment_id)
308
+ local_invalid! unless token.is_a?(Hash) && token[:address].is_a?(String)
309
+ address_matches = chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID ?
310
+ token[:address].casecmp?(address) : token[:address] == address
311
+ local_invalid! unless token[:deployment_id] == deployment_id && token[:chain_id] == chain_id &&
312
+ address_matches && token[:standard] == standard && token[:decimals] == 6 && token[:status] == "active"
313
+ end
314
+
315
+ def local_quote_validation_copy(quote)
316
+ copy = deep_dup(quote)
317
+ raw = copy.fetch("rawSignedQuoteJson")
318
+ local_invalid! unless raw.is_a?(String)
319
+ # Local fixture snapshots retain the provider's trailing JSON whitespace.
320
+ # The hosted parser keeps its exact framing contract, so validate only a
321
+ # local copy with RFC JSON whitespace removed from the end.
322
+ copy["rawSignedQuoteJson"] = raw.sub(/[ \t\r\n]+\z/, "")
323
+ copy
324
+ end
325
+
326
+ def validate_local_route(quote, client)
327
+ route = route_for_quote(quote)
328
+ local_invalid! unless route.row["providerId"] == "mayan-swift-v2" && route.row["status"] == "active"
329
+ assert_catalog_token(route.source_token_deployment_id, route.source_chain_id, route.source_token_address, route.source_token_standard)
330
+ assert_catalog_token(route.destination_token_deployment_id, route.destination_chain_id, route.destination_token_address, route.destination_token_standard)
331
+ assert_catalog_token(route.source_usdc_deployment_id, route.source_chain_id, route.source_usdc_address, route.source_usdc_standard)
332
+ begin
333
+ validation_quote = local_quote_validation_copy(quote)
334
+ hosted_route, = client.__send__(:build_route_from_quote, validation_quote)
335
+ client.__send__(:validate_raw_quote_for_build, validation_quote, hosted_route)
336
+ local_invalid! unless hosted_route.facts.source_chain_id == route.source_chain_id &&
337
+ hosted_route.facts.destination_chain_id == route.destination_chain_id &&
338
+ hosted_route.facts.source_token_deployment_id == route.source_token_deployment_id &&
339
+ hosted_route.facts.destination_token_deployment_id == route.destination_token_deployment_id
340
+ rescue BridgeError => error
341
+ raise error if error.code == BridgeErrorCode::QUOTE_EXPIRED
342
+ local_invalid!
343
+ end
344
+ route
345
+ end
346
+
347
+ def local_json_object(text)
348
+ local_invalid! unless text.is_a?(String)
349
+ root = begin
350
+ StrictJsonParser.new(text).parse
351
+ rescue BridgeError
352
+ local_invalid!
353
+ end
354
+ local_invalid! unless root.object_entries && root.value.is_a?(Hash)
355
+ [root, root.value]
356
+ end
357
+
358
+ def node_value(root, key)
359
+ entry = root.object_entries&.fetch(key, nil)
360
+ entry ? entry.value : :__bridge_missing__
361
+ end
362
+
363
+ def required_node(root, key)
364
+ entry = root.object_entries&.fetch(key, nil)
365
+ local_invalid! unless entry
366
+ entry
367
+ end
368
+
369
+ def raw_number_lexeme(root, key)
370
+ value = required_node(root, key).raw_number
371
+ local_invalid! unless value.is_a?(String)
372
+ value
373
+ end
374
+
375
+ def raw_number_zero(value)
376
+ return false if value == true || value == false
377
+ return value.finite? && value.zero? if value.is_a?(Numeric)
378
+
379
+ value == "0"
380
+ end
381
+
382
+ def raw_uint64(raw, key, required: true)
383
+ value = raw.fetch(key, :__bridge_missing__)
384
+ return 0 if value == :__bridge_missing__ && !required
385
+
386
+ uint64(value)
387
+ end
388
+
389
+ def raw_optional_bounded_integer(raw, key, maximum)
390
+ value = raw.fetch(key, :__bridge_missing__)
391
+ return nil if value == :__bridge_missing__
392
+ local_invalid! if value == true || value == false || !value.is_a?(Numeric) || !value.finite? || value < 0 || value.to_i != value || value.to_i > maximum
393
+
394
+ value.to_i
395
+ end
396
+
397
+ def raw_address_equals(value, expected)
398
+ return false unless value.is_a?(String)
399
+ MayanSwiftV2BridgeClient::EVM_ADDRESS.match?(expected) ? value.casecmp?(expected) : value == expected
400
+ end
401
+
402
+ ExactDecimal = Struct.new(:source, :integer, :scale, keyword_init: true)
403
+
404
+ def parse_plain_decimal(value)
405
+ local_invalid! unless value.is_a?(String) && PLAIN_DECIMAL.match?(value)
406
+ separator = value.index(".")
407
+ integer_part = separator ? value[0...separator] : value
408
+ fractional_part = separator ? value[(separator + 1)..] : ""
409
+ integer = Integer(integer_part, 10)
410
+ kept = fractional_part[0, 6].to_s.ljust(6, "0")
411
+ result = integer * 1_000_000 + Integer(kept.empty? ? "0" : kept, 10)
412
+ local_invalid! if result <= 0 || result > UINT64_MAX
413
+ ExactDecimal.new(source: value, integer: result, scale: fractional_part.length)
414
+ rescue ArgumentError
415
+ local_invalid!
416
+ end
417
+
418
+ # Reproduce the pinned JS conversion without formatting through a locale or
419
+ # a decimal float. Ruby Float is IEEE754 binary64 on supported runtimes.
420
+ def binary64_rounded_decimal_units(value)
421
+ local_invalid! unless value.is_a?(String)
422
+ number = Float(value)
423
+ local_invalid! unless number.finite? && number.positive?
424
+ bits = [number].pack("G").unpack1("Q>")
425
+ exponent = (bits >> 52) & 0x7ff
426
+ fraction = bits & ((1 << 52) - 1)
427
+ mantissa = exponent.zero? ? fraction : (1 << 52) | fraction
428
+ binary_exponent = exponent.zero? ? -1074 : exponent - 1023 - 52
429
+ numerator = mantissa * 10_000_000
430
+ denominator = 1
431
+ if binary_exponent >= 0
432
+ numerator <<= binary_exponent
433
+ else
434
+ denominator <<= -binary_exponent
435
+ end
436
+ rounded, remainder = numerator.divmod(denominator)
437
+ rounded += 1 if remainder * 2 >= denominator
438
+ result = rounded / 10
439
+ local_invalid! if result <= 0 || result > UINT64_MAX
440
+ result
441
+ rescue ArgumentError, RangeError
442
+ local_invalid!
443
+ end
444
+
445
+ def exact_intermediate_amount(raw, direct, amount_in)
446
+ decimal = parse_plain_decimal(raw)
447
+ exact = decimal.integer
448
+ if direct
449
+ local_invalid! unless exact == uint64(amount_in, positive: true)
450
+ return exact.to_s
451
+ end
452
+ local_invalid! unless binary64_rounded_decimal_units(raw) == exact
453
+ exact.to_s
454
+ end
455
+
456
+ def validate_destination_minimum_compatibility(value, root = nil)
457
+ units = uint64(value, positive: true)
458
+ if root
459
+ raw_lexeme = raw_number_lexeme(root, "minAmountOut")
460
+ local_invalid! unless parse_plain_decimal(raw_lexeme).integer == units && binary64_rounded_decimal_units(raw_lexeme) == units
461
+ else
462
+ whole, remainder = units.divmod(1_000_000)
463
+ decimal = "#{whole}.#{format('%06d', remainder)}"
464
+ local_invalid! unless binary64_rounded_decimal_units(decimal) == units
465
+ end
466
+ end
467
+
468
+ ED25519_P = (1 << 255) - 19
469
+ ED25519_D = (-121665 * 121666.pow(ED25519_P - 2, ED25519_P)) % ED25519_P
470
+ ED25519_SQRT_M1 = 2.pow((ED25519_P - 1) / 4, ED25519_P)
471
+
472
+ def is_on_curve_zip215(bytes)
473
+ return false unless bytes.is_a?(String) && bytes.bytesize == 32
474
+ # Use the integer representation, which is deterministic and bounded.
475
+ encoded = bytes.bytes.each_with_index.sum { |byte, index| byte << (index * 8) }
476
+ sign = encoded >> 255
477
+ y = (encoded & ((1 << 255) - 1)) % ED25519_P
478
+ y_squared = (y * y) % ED25519_P
479
+ numerator = (y_squared - 1) % ED25519_P
480
+ denominator = (ED25519_D * y_squared + 1) % ED25519_P
481
+ return false if denominator.zero?
482
+ x_squared = numerator * denominator.pow(ED25519_P - 2, ED25519_P) % ED25519_P
483
+ x = x_squared.pow((ED25519_P + 3) / 8, ED25519_P)
484
+ if (x * x - x_squared) % ED25519_P != 0
485
+ x = x * ED25519_SQRT_M1 % ED25519_P
486
+ end
487
+ return false if (x * x - x_squared) % ED25519_P != 0
488
+ x = ED25519_P - x if !x.zero? && (x & 1) != sign
489
+ true
490
+ rescue StandardError
491
+ false
492
+ end
493
+
494
+ def find_program_address(seeds, program_id)
495
+ local_invalid! unless seeds.is_a?(Array) && seeds.length <= 16 && seeds.all? { |seed| seed.is_a?(String) && seed.bytesize <= 32 }
496
+ program = base58_decode(program_id)
497
+ local_invalid! unless program.bytesize == 32
498
+ suffix = "ProgramDerivedAddress".b
499
+ 255.downto(0) do |bump|
500
+ candidate = Digest::SHA256.digest(concat(*seeds, [bump].pack("C"), program, suffix))
501
+ return [base58_encode(candidate), bump] unless is_on_curve_zip215(candidate)
502
+ end
503
+ local_invalid!
504
+ end
505
+
506
+ def associated_token_address(owner, mint, allow_owner_off_curve)
507
+ owner_bytes = base58_decode(owner)
508
+ mint_bytes = base58_decode(mint)
509
+ local_invalid! unless owner_bytes.bytesize == 32 && mint_bytes.bytesize == 32
510
+ local_invalid! if !allow_owner_off_curve && !is_on_curve_zip215(owner_bytes)
511
+ find_program_address([owner_bytes, base58_decode(SOLANA_TOKEN_PROGRAM), mint_bytes], SOLANA_ASSOCIATED_TOKEN_PROGRAM).first
512
+ end
513
+
514
+ def parse_source_quote(quote, route)
515
+ raw_text = quote.fetch("rawSignedQuoteJson")
516
+ root, raw = local_json_object(raw_text)
517
+ minimum_lexeme = raw_number_lexeme(root, "minMiddleAmount")
518
+ from_token = node_value(root, "fromToken")
519
+ to_token = node_value(root, "toToken")
520
+ local_invalid! unless from_token.is_a?(Hash) && to_token.is_a?(Hash)
521
+ source_standard = route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID ? "erc20" : "spl"
522
+ destination_standard = route.destination_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID ? "erc20" : "spl"
523
+ source_swap = quote.fetch("sourceSwap")
524
+ local_invalid! unless source_swap.is_a?(Hash)
525
+ local_invalid! unless raw["signature"].is_a?(String) && MayanSwiftV2BridgeClient::EVM_SIGNATURE.match?(raw["signature"]) &&
526
+ raw["signature"].casecmp?(quote.fetch("providerSignature"))
527
+ local_invalid! unless raw["slippageBps"] == quote.fetch("slippageBps")
528
+ local_invalid! unless source_swap["providerMinimumAmount"] == minimum_lexeme
529
+ raw_router = raw.fetch("evmSwapRouterAddress", :__bridge_missing__)
530
+ if route.is_usdc || route.source_chain_id == MayanSwiftV2BridgeClient::SOLANA_CHAIN_ID
531
+ local_invalid! unless raw_router == :__bridge_missing__ || raw_router.nil?
532
+ else
533
+ local_invalid! unless raw_router.is_a?(String) && EVM_ADDRESS.match?(raw_router) &&
534
+ source_swap["routerKind"] == "provider-selected-evm" && raw_address_equals(raw_router, source_swap["routerAddress"])
535
+ end
536
+ raw_calldata = raw.fetch("evmSwapRouterCalldata", :__bridge_missing__)
537
+ if raw_calldata != :__bridge_missing__ && raw_calldata != nil
538
+ local_invalid! unless raw_calldata.is_a?(String) && HEX_BYTES.match?(raw_calldata) && raw_calldata != "0x" &&
539
+ hex_to_bytes(raw_calldata).bytesize <= MAX_ROUTER_CALLDATA_BYTES
540
+ end
541
+ local_invalid! unless raw["type"] == "SWIFT" && raw["swiftVersion"] == "V2" && raw["gasless"] == false &&
542
+ raw["onlyBridging"] == false && raw["swiftWrapAndLock"] != true &&
543
+ raw["fromChain"] == route.source_name && raw["toChain"] == route.destination_name &&
544
+ raw["effectiveAmountIn64"] == quote["amountIn"] && raw["expectedAmountOutBaseUnits"] == quote["expectedAmountOut"] &&
545
+ raw["minAmountOutBaseUnits"] == quote["minimumAmountOut"] && raw["minReceivedBaseUnits"] == quote["minimumReceived"] &&
546
+ raw["deadline64"] == quote["deadline"] && raw["quoteId"].is_a?(String) && raw["quoteId"].casecmp?(quote["quoteId"].to_s) &&
547
+ raw["swiftInputDecimals"] == 6 && raw_address_equals(raw["swiftInputContract"], route.source_usdc_address) &&
548
+ raw["swiftInputContractStandard"] == source_standard && raw_address_equals(raw["swiftMayanContract"], route.swift_contract) &&
549
+ raw["referrerBps"] == 0 && raw["protocolBps"] == 0 && raw_number_zero(raw["gasDrop"]) &&
550
+ raw_address_equals(from_token["contract"], route.source_token_address) && from_token["standard"] == source_standard && from_token["decimals"] == 6 &&
551
+ raw_address_equals(to_token["contract"], route.destination_token_address) && to_token["standard"] == destination_standard && to_token["decimals"] == 6
552
+ mode = raw["swiftAuctionMode"]
553
+ local_invalid! unless mode == 2 || mode == 3
554
+ expected_mode = route.is_usdc ? 3 : 2
555
+ local_invalid! unless mode == expected_mode
556
+ local_invalid! if mode == 3 && (raw["expectedAmountOutBaseUnits"] != raw["minAmountOutBaseUnits"] || raw["minAmountOutBaseUnits"] != raw["minReceivedBaseUnits"])
557
+ local_invalid! if raw.key?("memoHex")
558
+ %w[customPayload referrer referrerAddress swiftRefundAddress permit approval approvalBatch separateSwapTx jito extraInstructions].each do |key|
559
+ value = raw.fetch(key, :__bridge_missing__)
560
+ local_invalid! if value != :__bridge_missing__ && ![nil, false, ""].include?(value)
561
+ end
562
+ minimum_intermediate_amount = exact_intermediate_amount(minimum_lexeme, route.is_usdc, quote.fetch("amountIn"))
563
+ unless route.is_usdc
564
+ numeric = Float(minimum_lexeme)
565
+ local_invalid! unless numeric.finite? && numeric.positive?
566
+ end
567
+ ParsedSourceQuote.new(
568
+ raw: raw, root: root, minimum_intermediate_amount: minimum_intermediate_amount,
569
+ mode: mode, cancel_fee: raw_uint64(raw, "cancelRelayerFee64"),
570
+ refund_fee: raw_uint64(raw, "refundRelayerFee64"), submit_fee: raw_uint64(raw, "submitRelayerFee64"),
571
+ suggested_priority_fee: raw_optional_bounded_integer(raw, "suggestedPriorityFee", 100_000)
572
+ )
573
+ rescue KeyError, TypeError, ArgumentError
574
+ local_invalid!
575
+ end
576
+
577
+ def base64_decode(value, maximum_bytes = MAX_SOLANA_SWAP_DATA_BYTES)
578
+ local_invalid! unless value.is_a?(String) && value.bytesize <= ((maximum_bytes * 4.0 / 3).ceil + 4) && BASE64.match?(value)
579
+ decoded = Base64.strict_decode64(value)
580
+ local_invalid! unless decoded.bytesize <= maximum_bytes && Base64.strict_encode64(decoded) == value
581
+ decoded
582
+ rescue ArgumentError
583
+ local_invalid!
584
+ end
585
+
586
+ def instruction_from_raw(value)
587
+ local_invalid! unless value.is_a?(Hash) && value.keys.sort == %w[accounts data programId]
588
+ program_id = canonical_solana_address(value["programId"])
589
+ raw_accounts = value["accounts"]
590
+ local_invalid! unless raw_accounts.is_a?(Array) && raw_accounts.length <= MAX_SOLANA_SWAP_ACCOUNTS
591
+ accounts = raw_accounts.map do |raw_account|
592
+ local_invalid! unless raw_account.is_a?(Hash) && raw_account.keys.sort == %w[isSigner isWritable pubkey]
593
+ local_invalid! unless raw_account["isSigner"] == true || raw_account["isSigner"] == false
594
+ local_invalid! unless raw_account["isWritable"] == true || raw_account["isWritable"] == false
595
+ { "pubkey" => canonical_solana_address(raw_account["pubkey"]), "isSigner" => raw_account["isSigner"], "isWritable" => raw_account["isWritable"] }
596
+ end
597
+ data = value["data"]
598
+ base64_decode(data)
599
+ { "programId" => program_id, "accounts" => accounts, "dataBase64" => data }
600
+ end
601
+
602
+ def encode_instruction_data(value)
603
+ base64_decode(value)
604
+ end
605
+
606
+ def account_at(instruction, index)
607
+ value = instruction.fetch("accounts")[index]
608
+ local_invalid! unless value
609
+ value
610
+ end
611
+
612
+ def assert_account(account, pubkey, signer, writable)
613
+ local_invalid! unless account["pubkey"] == pubkey && account["isSigner"] == signer && account["isWritable"] == writable
614
+ end
615
+
616
+ def validate_compute_instructions(instructions)
617
+ local_invalid! unless instructions.length <= 2
618
+ tags = []
619
+ instructions.each do |instruction|
620
+ local_invalid! unless instruction["programId"] == SOLANA_COMPUTE_BUDGET_PROGRAM && instruction["accounts"].empty?
621
+ data = encode_instruction_data(instruction["dataBase64"])
622
+ tag = data.getbyte(0)
623
+ local_invalid! unless [2, 3].include?(tag) && !tags.include?(tag)
624
+ tags << tag
625
+ if tag == 2
626
+ local_invalid! unless data.bytesize == 5 && data.byteslice(1, 4).unpack1("V") <= 1_400_000
627
+ else
628
+ local_invalid! unless data.bytesize == 9 && data.byteslice(1, 8).unpack1("Q<") <= 100_000
629
+ end
630
+ end
631
+ end
632
+
633
+ def validate_ata_setup(instructions, swapper_address, state_address, source_usdc_address)
634
+ local_invalid! unless !instructions.empty? && instructions.length <= 2
635
+ owners = []
636
+ instructions.each do |instruction|
637
+ local_invalid! unless instruction["programId"] == SOLANA_ASSOCIATED_TOKEN_PROGRAM && instruction["accounts"].length == 6
638
+ assert_account(account_at(instruction, 0), swapper_address, true, true)
639
+ owner = account_at(instruction, 2)["pubkey"]
640
+ local_invalid! unless [swapper_address, state_address].include?(owner) && !owners.include?(owner)
641
+ owners << owner
642
+ assert_account(account_at(instruction, 2), owner, false, false)
643
+ expected_ata = associated_token_address(owner, source_usdc_address, owner == state_address)
644
+ assert_account(account_at(instruction, 1), expected_ata, false, true)
645
+ assert_account(account_at(instruction, 3), source_usdc_address, false, false)
646
+ assert_account(account_at(instruction, 4), SOLANA_SYSTEM_PROGRAM, false, false)
647
+ assert_account(account_at(instruction, 5), SOLANA_TOKEN_PROGRAM, false, false)
648
+ local_invalid! unless ["", "AQ=="].include?(instruction["dataBase64"])
649
+ end
650
+ local_invalid! unless owners.include?(state_address)
651
+ end
652
+
653
+ def validate_jupiter_instruction(instruction, route, quote, state_token_account, swapper_address, minimum_intermediate_amount, source_swap_raw)
654
+ local_invalid! unless instruction["programId"] == SOLANA_JUPITER_V6 && instruction["accounts"].length.between?(8, MAX_SOLANA_SWAP_ACCOUNTS)
655
+ data = encode_instruction_data(instruction["dataBase64"])
656
+ local_invalid! unless data.byteslice(0, 8).unpack1("H*") == SOLANA_ROUTE_V2_DISCRIMINATOR
657
+ quote_response = source_swap_raw["quoteResponse"]
658
+ local_invalid! unless quote_response.is_a?(Hash)
659
+ quote_response_raw = quote_response["raw"]
660
+ local_invalid! unless quote_response_raw.is_a?(Hash)
661
+ route_plan = quote_response_raw["routePlan"]
662
+ local_invalid! unless route_plan.is_a?(Array) && route_plan.length == 1 && route_plan[0].is_a?(Hash)
663
+ swap_info = route_plan[0]["swapInfo"]
664
+ local_invalid! unless swap_info.is_a?(Hash)
665
+ local_invalid! unless raw_address_equals(quote_response["inputMint"], route.source_token_address) && raw_address_equals(quote_response["outputMint"], route.source_usdc_address) &&
666
+ raw_address_equals(quote_response_raw["inputMint"], route.source_token_address) && raw_address_equals(quote_response_raw["outputMint"], route.source_usdc_address) &&
667
+ raw_address_equals(swap_info["inputMint"], route.source_token_address) && raw_address_equals(swap_info["outputMint"], route.source_usdc_address) &&
668
+ swap_info["inAmount"].is_a?(String) && swap_info["outAmount"].is_a?(String) &&
669
+ uint64(swap_info["inAmount"], positive: true) == uint64(quote.fetch("amountIn"), positive: true) &&
670
+ uint64(swap_info["outAmount"], positive: true) >= uint64(minimum_intermediate_amount, positive: true)
671
+ label = swap_info["label"]
672
+ expected_tail, expected_count, _expected_dex, expected_pool = case label
673
+ when "Whirlpool"
674
+ [SOLANA_ROUTE_V2_WHIRLPOOL_TAIL, 22, SOLANA_WHIRLPOOL_PROGRAM, SOLANA_WHIRLPOOL_POOL]
675
+ when "Raydium CLMM"
676
+ [SOLANA_ROUTE_V2_RAYDIUM_TAIL, 25, SOLANA_RAYDIUM_CLMM_PROGRAM, SOLANA_RAYDIUM_CLMM_POOL]
677
+ else
678
+ local_invalid!
679
+ end
680
+ local_invalid! unless data.bytesize == 28 + expected_tail.length / 2 && data.byteslice(28..).unpack1("H*") == expected_tail && instruction["accounts"].length == expected_count
681
+ trader_eurc = associated_token_address(swapper_address, route.source_token_address, false)
682
+ trader_usdc = associated_token_address(swapper_address, route.source_usdc_address, false)
683
+ expected_accounts = if label == "Whirlpool"
684
+ [
685
+ [swapper_address, true, false], [trader_eurc, false, true], [trader_usdc, false, true],
686
+ [route.source_token_address, false, false], [route.source_usdc_address, false, false],
687
+ [SOLANA_TOKEN_PROGRAM, false, false], [SOLANA_TOKEN_PROGRAM, false, false],
688
+ [state_token_account, false, true], [SOLANA_ANCHOR_EVENT_AUTHORITY, false, false],
689
+ [SOLANA_JUPITER_V6, false, false], [SOLANA_WHIRLPOOL_PROGRAM, false, false],
690
+ [SOLANA_TOKEN_PROGRAM, false, false], [swapper_address, false, false],
691
+ [SOLANA_WHIRLPOOL_POOL, false, true], [trader_usdc, false, true],
692
+ [SOLANA_WHIRLPOOL_TICK_ARRAY_0, false, true], [trader_eurc, false, true],
693
+ [SOLANA_WHIRLPOOL_TICK_ARRAY_1, false, true], [SOLANA_WHIRLPOOL_TICK_ARRAY_2, false, true],
694
+ [SOLANA_WHIRLPOOL_ORACLE, false, true], [SOLANA_WHIRLPOOL_REWARD_VAULT, false, true],
695
+ [SOLANA_WHIRLPOOL_REMAINING, false, false]
696
+ ]
697
+ else
698
+ [
699
+ [swapper_address, true, false], [trader_eurc, false, true], [trader_usdc, false, true],
700
+ [route.source_token_address, false, false], [route.source_usdc_address, false, false],
701
+ [SOLANA_TOKEN_PROGRAM, false, false], [SOLANA_TOKEN_PROGRAM, false, false],
702
+ [state_token_account, false, true], [SOLANA_ANCHOR_EVENT_AUTHORITY, false, false],
703
+ [SOLANA_JUPITER_V6, false, false], [SOLANA_RAYDIUM_CLMM_PROGRAM, false, false],
704
+ [swapper_address, false, false], [SOLANA_RAYDIUM_CONFIG, false, false],
705
+ [SOLANA_RAYDIUM_CLMM_POOL, false, true], [trader_eurc, false, true], [trader_usdc, false, true],
706
+ [SOLANA_RAYDIUM_OBSERVATION, false, true], [SOLANA_RAYDIUM_TICK_ARRAY_0, false, true],
707
+ [SOLANA_RAYDIUM_TICK_ARRAY_1, false, true], [SOLANA_TOKEN_PROGRAM, false, false],
708
+ [SOLANA_RAYDIUM_TICK_ARRAY_2, false, true], [SOLANA_RAYDIUM_TICK_ARRAY_3, false, true],
709
+ [SOLANA_RAYDIUM_ORACLE, false, true], [SOLANA_RAYDIUM_REMAINING, false, true],
710
+ [SOLANA_JUPITER_V6, false, false]
711
+ ]
712
+ end
713
+ local_invalid! unless expected_accounts.length == instruction["accounts"].length
714
+ expected_accounts.each_with_index do |(pubkey, signer, writable), index|
715
+ assert_account(account_at(instruction, index), pubkey, signer, writable)
716
+ end
717
+ local_invalid! unless swap_info["ammKey"] == expected_pool
718
+ input_amount = data.byteslice(8, 8).unpack1("Q<")
719
+ quoted_output = data.byteslice(16, 8).unpack1("Q<")
720
+ slippage_bps = data.byteslice(24, 2).unpack1("v")
721
+ platform_bps = data.byteslice(26, 2).unpack1("v")
722
+ local_invalid! unless input_amount == uint64(quote.fetch("amountIn"), positive: true) && quoted_output >= uint64(minimum_intermediate_amount, positive: true) && platform_bps.zero? && slippage_bps <= 10_000
723
+ end
724
+
725
+ def validate_source_swap_envelope(quote, route, raw, state_address, state_token_account, swapper_address, minimum_intermediate_amount)
726
+ local_invalid! if route.is_usdc
727
+ if route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID
728
+ local_invalid! unless raw.keys.sort == %w[swapRouterAddress swapRouterCalldata]
729
+ router = normalize_evm_address(raw["swapRouterAddress"])
730
+ source_swap = quote["sourceSwap"]
731
+ local_invalid! unless source_swap.is_a?(Hash) && source_swap["routerAddress"] == router && source_swap["routerKind"] == "provider-selected-evm"
732
+ calldata = raw["swapRouterCalldata"]
733
+ local_invalid! unless calldata.is_a?(String) && HEX_BYTES.match?(calldata)
734
+ local_invalid! unless calldata != "0x" && calldata.downcase.start_with?(EVM_SOURCE_SWAP_SELECTOR) && hex_to_bytes(calldata).bytesize <= MAX_ROUTER_CALLDATA_BYTES
735
+ return { "kind" => "evm-router", "routerAddress" => router, "calldata" => calldata.downcase, "rawResponseSha256" => "", "rawProviderSourceSwapJson" => "" }
736
+ end
737
+ token_ledger = raw.fetch("tokenLedgerInstruction", :__bridge_missing__)
738
+ local_invalid! if token_ledger != :__bridge_missing__ && !token_ledger.nil?
739
+ compute_value = raw["computeBudgetInstructions"]
740
+ setup_value = raw["setupInstructions"]
741
+ swap_value = raw.fetch("swapInstruction", :__bridge_missing__)
742
+ local_invalid! unless compute_value.is_a?(Array) && setup_value.is_a?(Array) && swap_value.is_a?(Hash)
743
+ cleanup = raw.fetch("cleanupInstruction", :__bridge_missing__)
744
+ local_invalid! if cleanup != :__bridge_missing__ && !cleanup.nil?
745
+ local_invalid! unless raw["otherInstructions"].is_a?(Array) && raw["otherInstructions"].empty?
746
+ simulation_error = raw.fetch("simulationError", :__bridge_missing__)
747
+ local_invalid! if simulation_error != :__bridge_missing__ && !simulation_error.nil?
748
+ separate_swap = raw.fetch("separateSwapTx", :__bridge_missing__)
749
+ local_invalid! if separate_swap != :__bridge_missing__ && separate_swap != false
750
+ jito = raw.fetch("jito", :__bridge_missing__)
751
+ local_invalid! if jito != :__bridge_missing__ && ![false, nil].include?(jito)
752
+ compute = compute_value.map { |item| instruction_from_raw(item) }
753
+ setup = setup_value.map { |item| instruction_from_raw(item) }
754
+ swap = instruction_from_raw(swap_value)
755
+ validate_compute_instructions(compute)
756
+ validate_ata_setup(setup, swapper_address, state_address, route.source_usdc_address)
757
+ validate_jupiter_instruction(swap, route, quote, state_token_account, swapper_address, minimum_intermediate_amount, raw)
758
+ provider_alts = raw["addressLookupTableAddresses"]
759
+ local_invalid! unless provider_alts.is_a?(Array) && provider_alts.length <= MAX_LOOKUP_TABLES && provider_alts.all? { |address| address.is_a?(String) && canonical_solana_address(address) == address }
760
+ priority_fee = raw.fetch("prioritizationFeeLamports", :__bridge_missing__)
761
+ local_invalid! if priority_fee != :__bridge_missing__ && (!priority_fee.is_a?(Integer) || priority_fee.negative?)
762
+ compute_limit = raw.fetch("computeUnitLimit", :__bridge_missing__)
763
+ local_invalid! if compute_limit != :__bridge_missing__ && (!compute_limit.is_a?(Integer) || compute_limit > 1_400_000)
764
+ { "kind" => "solana-jupiter-v6", "instructions" => compute + setup + [swap], "addressLookupTableAddresses" => provider_alts, "rawResponseSha256" => "", "rawProviderSourceSwapJson" => "" }
765
+ end
766
+
767
+ def swift_random(quote_id, order_nonce)
768
+ hex_to_bytes(quote_id) + hex_to_bytes(order_nonce)
769
+ end
770
+
771
+ def write_uint16_be(value)
772
+ local_invalid! unless value.is_a?(Integer) && value >= 0 && value <= 0xffff
773
+ [value].pack("n")
774
+ end
775
+
776
+ def write_uint16_le(value)
777
+ local_invalid! unless value.is_a?(Integer) && value >= 0 && value <= 0xffff
778
+ [value].pack("v")
779
+ end
780
+
781
+ def write_uint64_be(value)
782
+ local_invalid! unless value.is_a?(Integer) && value >= 0 && value <= UINT64_MAX
783
+ [value].pack("Q>")
784
+ end
785
+
786
+ def write_uint64_le(value)
787
+ local_invalid! unless value.is_a?(Integer) && value >= 0 && value <= UINT64_MAX
788
+ [value].pack("Q<")
789
+ end
790
+
791
+ def zero32
792
+ "\0".b * 32
793
+ end
794
+
795
+ def order_preimage(quote, route, swapper_address, destination_address, order_nonce, cancel_fee, refund_fee, mode)
796
+ parts = [
797
+ "\x01".b,
798
+ native_address_bytes(swapper_address, route.source_chain_id),
799
+ write_uint16_be(route.source_wormhole_chain_id),
800
+ native_address_bytes(route.source_usdc_address, route.source_chain_id),
801
+ native_address_bytes(destination_address, route.destination_chain_id),
802
+ write_uint16_be(route.destination_wormhole_chain_id),
803
+ native_address_bytes(route.destination_token_address, route.destination_chain_id),
804
+ write_uint64_be(uint64(quote.fetch("minimumAmountOut"), positive: true)),
805
+ write_uint64_be(0), write_uint64_be(cancel_fee), write_uint64_be(refund_fee),
806
+ write_uint64_be(uint64(quote.fetch("deadline"), positive: true)), zero32,
807
+ [0, 0, mode].pack("C3"), swift_random(quote.fetch("quoteId"), order_nonce), zero32
808
+ ]
809
+ result = concat(*parts)
810
+ local_invalid! unless result.bytesize == 272
811
+ result
812
+ end
813
+
814
+ def hash_order(quote, route, swapper_address, destination_address, order_nonce, cancel_fee, refund_fee, mode)
815
+ "0x#{keccak_hex(order_preimage(quote, route, swapper_address, destination_address, order_nonce, cancel_fee, refund_fee, mode))}"
816
+ end
817
+
818
+ def quote_binding_hash(route, quote, raw_quote_sha256, order_nonce, swapper_address, destination_address)
819
+ value = {
820
+ "capabilityId" => route.capability_id,
821
+ "destinationAddress" => destination_address,
822
+ "destinationChainId" => route.destination_chain_id,
823
+ "destinationTokenDeploymentId" => route.destination_token_deployment_id,
824
+ "orderNonce" => order_nonce,
825
+ "quoteId" => quote.fetch("quoteId"),
826
+ "rawQuoteSha256" => raw_quote_sha256,
827
+ "sourceChainId" => route.source_chain_id,
828
+ "sourceTokenDeploymentId" => route.source_token_deployment_id,
829
+ "swapperAddress" => swapper_address
830
+ }
831
+ sha256_hex(stable_json(value))
832
+ end
833
+
834
+ def source_swap_hash_projection(source_swap)
835
+ return source_swap if source_swap["kind"] == "none"
836
+
837
+ source_swap.reject { |key, _| key == "rawProviderSourceSwapJson" }
838
+ end
839
+
840
+ def plan_hash(binding, source_swap)
841
+ sha256_hex(stable_json("quoteBindingHash" => binding, "sourceSwap" => source_swap_hash_projection(source_swap)))
842
+ end
843
+
844
+ def config_value(config, *names)
845
+ return :__bridge_missing__ unless config.is_a?(Hash)
846
+ names.each do |name|
847
+ return config[name] if config.key?(name)
848
+ symbol = name.to_sym
849
+ return config[symbol] if config.key?(symbol)
850
+ end
851
+ :__bridge_missing__
852
+ end
853
+
854
+ def endpoint_url(value)
855
+ source = value == :__bridge_missing__ || value.nil? ? SOLANA_MAYAN_SOURCE_SWAP_ENDPOINT : value
856
+ local_invalid! unless source.is_a?(String) && !source.empty? && source.strip == source
857
+ uri = URI.parse(source)
858
+ hostname = uri.hostname.to_s.downcase
859
+ scheme = uri.scheme.to_s.downcase
860
+ local_invalid! if uri.user || uri.password || uri.query || uri.fragment || uri.host.to_s.empty?
861
+ local_invalid! unless scheme == "https" || (scheme == "http" && %w[localhost 127.0.0.1 ::1].include?(hostname))
862
+ uri.scheme = scheme
863
+ uri.path = uri.path.to_s.sub(%r{/+\z}, "")
864
+ uri.path = "/" if uri.path.empty?
865
+ uri.to_s
866
+ rescue URI::InvalidURIError, ArgumentError
867
+ local_invalid!
868
+ end
869
+
870
+ def js_string(value)
871
+ return value ? "true" : "false" if value == true || value == false
872
+ if value.is_a?(Float)
873
+ local_invalid! unless value.finite?
874
+ return value.to_i.to_s if value.to_i == value
875
+ return value.to_s
876
+ end
877
+ value.to_s
878
+ end
879
+
880
+ def source_swap_url(endpoint, chain, params)
881
+ uri = URI.parse(endpoint)
882
+ base = uri.path.to_s.sub(%r{/+\z}, "")
883
+ uri.path = "#{base}/get-swap/#{chain}".gsub(%r{/+}, "/")
884
+ query = params.map { |key, value| "#{URI.encode_www_form_component(key.to_s)}=#{URI.encode_www_form_component(js_string(value))}" }.join("&")
885
+ uri.query = query
886
+ uri.fragment = nil
887
+ uri.to_s
888
+ rescue URI::InvalidURIError, ArgumentError
889
+ local_invalid!
890
+ end
891
+
892
+ def context_mapping(value, build:)
893
+ local_invalid! unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
894
+ expected = build ? %w[destinationAddress orderNonce quote sourceSwapPlan swapperAddress] : %w[destinationAddress orderNonce quote swapperAddress]
895
+ local_invalid! unless value.keys.sort == expected.sort
896
+ value
897
+ end
898
+
899
+ def normalize_local_address(value, chain_id)
900
+ chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID ? normalize_evm_address(value) : canonical_solana_address(value)
901
+ end
902
+
903
+ def validate_local_expiry(quote, client)
904
+ deadline = uint64(quote.fetch("deadline"), positive: true)
905
+ now = client.instance_variable_get(:@clock).call.to_i
906
+ local_invalid! if now + client.config.minimum_quote_validity_seconds > deadline
907
+ rescue KeyError, NoMethodError
908
+ local_invalid!
909
+ end
910
+
911
+ def check_aborted(client, options)
912
+ client.__send__(:check_aborted, options)
913
+ rescue BridgeError => error
914
+ ERPC.raise_safe_bridge_error(error)
915
+ end
916
+
917
+ def local_build_config(client)
918
+ value = client.config.local_build
919
+ return {} if value.nil?
920
+ local_invalid! unless value.is_a?(Hash)
921
+ value
922
+ end
923
+
924
+ def local_monotonic_now
925
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
926
+ end
927
+
928
+ def local_deadline(timeout)
929
+ local_monotonic_now + timeout.to_f
930
+ end
931
+
932
+ def local_remaining(deadline)
933
+ remaining = deadline - local_monotonic_now
934
+ raise Timeout::Error if remaining <= 0
935
+
936
+ remaining
937
+ end
938
+
939
+ def local_with_deadline(deadline)
940
+ Timeout.timeout(local_remaining(deadline), Timeout::Error) { yield }
941
+ end
942
+
943
+ def dispose_queued_local_responses(queue)
944
+ loop do
945
+ event, value = queue.pop(true)
946
+ dispose_local_response(value) if event == :response
947
+ rescue ThreadError
948
+ break
949
+ end
950
+ end
951
+
952
+ def perform_local_adapter_request(client, options, deadline, request)
953
+ queue = Queue.new
954
+ worker = Thread.new do
955
+ begin
956
+ queue << [:response, client.instance_variable_get(:@http_adapter).request(**request)]
957
+ rescue Exception => error # rubocop:disable Lint/RescueException
958
+ queue << [:error, error]
959
+ end
960
+ end
961
+ completed = false
962
+ begin
963
+ loop do
964
+ check_aborted(client, options)
965
+ local_remaining(deadline)
966
+ event, value = begin
967
+ queue.pop(true)
968
+ rescue ThreadError
969
+ sleep(0.001)
970
+ next
971
+ end
972
+ completed = true
973
+ if event == :response
974
+ return value
975
+ end
976
+ ERPC.raise_safe_bridge_error(value) if value.is_a?(BridgeError)
977
+ raise value
978
+ end
979
+ ensure
980
+ unless completed
981
+ worker.kill
982
+ worker.join(RESPONSE_DISPOSAL_TIMEOUT)
983
+ dispose_queued_local_responses(queue)
984
+ else
985
+ worker.join(RESPONSE_DISPOSAL_TIMEOUT)
986
+ end
987
+ end
988
+ end
989
+
990
+ def source_swap_request(client, url, options)
991
+ deadline = local_deadline(client.config.timeout)
992
+ response = nil
993
+ begin
994
+ check_aborted(client, options)
995
+ response = perform_local_adapter_request(
996
+ client, options, deadline,
997
+ method: :get,
998
+ url: url,
999
+ headers: { "accept" => "application/json" },
1000
+ body: nil,
1001
+ timeout: local_remaining(deadline)
1002
+ )
1003
+ check_aborted(client, options)
1004
+ status = response_status(response)
1005
+ fail_local(BridgeErrorCode::PROVIDER_TRANSPORT) unless status.is_a?(Integer)
1006
+ fail_local(BridgeErrorCode::PROVIDER_HTTP, status) unless status.between?(200, 299)
1007
+ fail_local(BridgeErrorCode::PROVIDER_INVALID_RESPONSE) if response_declared_size(response).to_i > MAX_RESPONSE_BYTES
1008
+ body = local_with_deadline(deadline) do
1009
+ read_local_response_body(response, client, options, deadline)
1010
+ end
1011
+ root, raw = local_json_object(body)
1012
+ [body, root, raw]
1013
+ rescue BridgeError
1014
+ dispose_local_response(response)
1015
+ raise
1016
+ rescue MayanBridgeBodyTooLarge
1017
+ dispose_local_response(response)
1018
+ fail_local(BridgeErrorCode::PROVIDER_INVALID_RESPONSE)
1019
+ rescue TimeoutError, Timeout::Error
1020
+ dispose_local_response(response)
1021
+ fail_local(BridgeErrorCode::TIMEOUT)
1022
+ rescue LocalValidationError, LocalResponseTooLarge, LocalResponseInvalidEncoding
1023
+ dispose_local_response(response)
1024
+ fail_local(BridgeErrorCode::PROVIDER_INVALID_RESPONSE)
1025
+ rescue StandardError
1026
+ dispose_local_response(response)
1027
+ check_aborted(client, options)
1028
+ fail_local(BridgeErrorCode::PROVIDER_TRANSPORT)
1029
+ end
1030
+ end
1031
+
1032
+ def dispose_local_response(response)
1033
+ return if response.nil?
1034
+
1035
+ targets = [response]
1036
+ unless response.respond_to?(:read_body)
1037
+ begin
1038
+ body = if response.is_a?(Hash)
1039
+ response[:body] || response["body"]
1040
+ elsif response.respond_to?(:body)
1041
+ response.body
1042
+ end
1043
+ targets << body unless body.nil? || body.equal?(response)
1044
+ rescue StandardError
1045
+ # Disposal is best effort and must not replace a sanitized public error.
1046
+ end
1047
+ end
1048
+ targets.uniq.each do |target|
1049
+ %i[cancel close finish].each do |method|
1050
+ next unless target.respond_to?(method)
1051
+
1052
+ begin
1053
+ Timeout.timeout(RESPONSE_DISPOSAL_TIMEOUT, Timeout::Error) { target.public_send(method) }
1054
+ break
1055
+ rescue StandardError
1056
+ # Try the next best-effort disposal method within the fixed bound.
1057
+ end
1058
+ end
1059
+ end
1060
+ end
1061
+
1062
+ def response_status(response)
1063
+ if response.respond_to?(:status)
1064
+ response.status
1065
+ elsif response.is_a?(Hash)
1066
+ response[:status] || response["status"]
1067
+ end
1068
+ end
1069
+
1070
+ def response_declared_size(response)
1071
+ headers = if response.is_a?(Hash)
1072
+ response[:headers] || response["headers"]
1073
+ elsif response.respond_to?(:headers)
1074
+ response.headers
1075
+ end
1076
+ return nil unless headers
1077
+
1078
+ value = if headers.respond_to?(:each_pair)
1079
+ headers.each_pair.find { |name, _| name.to_s.downcase == "content-length" }&.last
1080
+ elsif headers.respond_to?(:[])
1081
+ headers["content-length"] || headers["Content-Length"]
1082
+ end
1083
+ return nil unless value.is_a?(String) || value.is_a?(Integer)
1084
+
1085
+ text = value.to_s
1086
+ return nil unless text.match?(/\A[0-9]+\z/)
1087
+
1088
+ Integer(text, 10)
1089
+ rescue ArgumentError, TypeError
1090
+ nil
1091
+ end
1092
+
1093
+ def response_body_source(response)
1094
+ if response.is_a?(Hash)
1095
+ response[:body] || response["body"]
1096
+ elsif response.respond_to?(:body)
1097
+ response.body
1098
+ end
1099
+ end
1100
+
1101
+ def read_response_chunks(response, queue)
1102
+ produced_bytes = 0
1103
+ enqueue = lambda do |chunk|
1104
+ raise LocalResponseInvalidEncoding unless chunk.is_a?(String)
1105
+
1106
+ produced_bytes += chunk.bytesize
1107
+ raise LocalResponseTooLarge if produced_bytes > MAX_RESPONSE_BYTES
1108
+
1109
+ queue.push([:chunk, chunk])
1110
+ end
1111
+ if response.respond_to?(:read_body)
1112
+ response.read_body { |chunk| enqueue.call(chunk) }
1113
+ else
1114
+ body = response_body_source(response)
1115
+ if body.respond_to?(:read) && !body.is_a?(String)
1116
+ loop do
1117
+ chunk = body.read(16_384)
1118
+ break if chunk.nil? || chunk.empty?
1119
+
1120
+ enqueue.call(chunk)
1121
+ end
1122
+ elsif body.respond_to?(:each) && !body.is_a?(String)
1123
+ body.each { |chunk| enqueue.call(chunk) }
1124
+ else
1125
+ enqueue.call(body)
1126
+ end
1127
+ end
1128
+ queue << [:done, nil]
1129
+ rescue Exception => error # rubocop:disable Lint/RescueException
1130
+ queue << [:error, error]
1131
+ end
1132
+
1133
+ def read_local_response_body(response, client, options, deadline = nil)
1134
+ chunks = []
1135
+ total = 0
1136
+ append = lambda do |chunk|
1137
+ check_aborted(client, options)
1138
+ local_remaining(deadline) unless deadline.nil?
1139
+ raise LocalResponseInvalidEncoding unless chunk.is_a?(String)
1140
+
1141
+ total += chunk.bytesize
1142
+ raise LocalResponseTooLarge if total > MAX_RESPONSE_BYTES
1143
+
1144
+ chunks << chunk.b
1145
+ end
1146
+ streaming = response.respond_to?(:read_body)
1147
+ source = streaming ? response : response_body_source(response)
1148
+ if streaming || (source.respond_to?(:read) && !source.is_a?(String)) || (source.respond_to?(:each) && !source.is_a?(String))
1149
+ queue = SizedQueue.new(STREAM_QUEUE_CAPACITY)
1150
+ reader = Thread.new { read_response_chunks(response, queue) }
1151
+ completed = false
1152
+ begin
1153
+ loop do
1154
+ check_aborted(client, options)
1155
+ local_remaining(deadline) unless deadline.nil?
1156
+ event, value = begin
1157
+ queue.pop(true)
1158
+ rescue ThreadError
1159
+ sleep(0.001)
1160
+ next
1161
+ end
1162
+ case event
1163
+ when :chunk
1164
+ append.call(value)
1165
+ when :done
1166
+ completed = true
1167
+ break
1168
+ when :error
1169
+ if value.is_a?(BridgeError)
1170
+ ERPC.raise_safe_bridge_error(value)
1171
+ end
1172
+ raise value
1173
+ end
1174
+ end
1175
+ ensure
1176
+ unless completed
1177
+ reader.kill
1178
+ reader.join(RESPONSE_DISPOSAL_TIMEOUT)
1179
+ else
1180
+ reader.join(RESPONSE_DISPOSAL_TIMEOUT)
1181
+ end
1182
+ end
1183
+ else
1184
+ append.call(source)
1185
+ end
1186
+ check_aborted(client, options)
1187
+ local_remaining(deadline) unless deadline.nil?
1188
+ text = chunks.join.b.force_encoding(Encoding::UTF_8)
1189
+ raise LocalResponseInvalidEncoding unless text.valid_encoding?
1190
+
1191
+ text
1192
+ end
1193
+
1194
+ def prepare_source_swap(client, context, options = nil)
1195
+ options ||= {}
1196
+ value = deep_dup(context)
1197
+ begin
1198
+ value = context_mapping(value, build: false)
1199
+ quote = value.fetch("quote")
1200
+ local_invalid! unless quote.is_a?(Hash)
1201
+ route = validate_local_route(quote, client)
1202
+ parsed_quote = parse_source_quote(quote, route)
1203
+ swapper = normalize_local_address(value.fetch("swapperAddress"), route.source_chain_id)
1204
+ destination = normalize_local_address(value.fetch("destinationAddress"), route.destination_chain_id)
1205
+ nonce = value.fetch("orderNonce")
1206
+ local_invalid! unless nonce.is_a?(String) && NONCE.match?(nonce)
1207
+ validate_destination_minimum_compatibility(quote.fetch("minimumAmountOut"), parsed_quote.root)
1208
+ validate_local_expiry(quote, client)
1209
+ raw_quote = quote.fetch("rawSignedQuoteJson")
1210
+ local_invalid! unless raw_quote.is_a?(String)
1211
+ raw_sha = sha256_hex(raw_quote)
1212
+ order_hash = hash_order(quote, route, swapper, destination, nonce, parsed_quote.cancel_fee, parsed_quote.refund_fee, parsed_quote.mode)
1213
+ binding = quote_binding_hash(route, quote, raw_sha, nonce, swapper, destination)
1214
+ rescue BridgeError
1215
+ raise
1216
+ rescue LocalValidationError, KeyError, TypeError, ArgumentError
1217
+ fail_local(BridgeErrorCode::LOCAL_PLAN_INVALID)
1218
+ rescue StandardError
1219
+ fail_local(BridgeErrorCode::LOCAL_PLAN_INVALID)
1220
+ end
1221
+
1222
+ source_swap = if route.is_usdc
1223
+ { "kind" => "none" }
1224
+ else
1225
+ begin
1226
+ endpoint = endpoint_url(config_value(local_build_config(client), "source_swap_endpoint", "sourceSwapEndpoint"))
1227
+ from_token = parsed_quote.raw["fromToken"]
1228
+ local_invalid! unless from_token.is_a?(Hash)
1229
+ url = if route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID
1230
+ source_swap_url(endpoint, "evm", [
1231
+ ["forwarderAddress", ETHEREUM_FORWARDER_PROVIDER],
1232
+ ["slippageBps", quote.fetch("slippageBps")],
1233
+ ["fromToken", from_token["contract"]],
1234
+ ["middleToken", route.source_usdc_address],
1235
+ ["chainName", route.source_name],
1236
+ ["amountIn64", quote.fetch("amountIn")],
1237
+ ["sdkVersion", MAYAN_ORACLE_SDK_VERSION]
1238
+ ])
1239
+ else
1240
+ state_address = find_program_address(["STATE_SOURCE".b, hex_to_bytes(order_hash), write_uint16_le(2)], SOLANA_SWIFT_PROGRAM).first
1241
+ minimum_number = Float(raw_number_lexeme(parsed_quote.root, "minMiddleAmount"))
1242
+ local_invalid! unless minimum_number.finite? && minimum_number.positive?
1243
+ source_swap_url(endpoint, "solana", [
1244
+ ["minMiddleAmount", minimum_number],
1245
+ ["middleToken", route.source_usdc_address],
1246
+ ["userWallet", swapper],
1247
+ ["slippageBps", quote.fetch("slippageBps")],
1248
+ ["fromToken", route.source_token_address],
1249
+ ["amountIn64", quote.fetch("amountIn")],
1250
+ ["depositMode", "SWIFT"],
1251
+ ["fillMaxAccounts", false],
1252
+ ["chainName", route.source_name],
1253
+ ["userLedger", state_address],
1254
+ ["sdkVersion", MAYAN_ORACLE_SDK_VERSION]
1255
+ ])
1256
+ end
1257
+ text, _root, raw = source_swap_request(client, url, options)
1258
+ if route.source_chain_id == MayanSwiftV2BridgeClient::SOLANA_CHAIN_ID
1259
+ state_address = find_program_address(["STATE_SOURCE".b, hex_to_bytes(order_hash), write_uint16_le(2)], SOLANA_SWIFT_PROGRAM).first
1260
+ state_token = associated_token_address(state_address, route.source_usdc_address, true)
1261
+ else
1262
+ state_address = ""
1263
+ state_token = ""
1264
+ end
1265
+ normalized = validate_source_swap_envelope(quote, route, raw, state_address, state_token, swapper, parsed_quote.minimum_intermediate_amount)
1266
+ local_invalid! if normalized["kind"] == "none"
1267
+ normalized["rawResponseSha256"] = sha256_hex(text)
1268
+ normalized["rawProviderSourceSwapJson"] = text
1269
+ normalized
1270
+ rescue BridgeError
1271
+ raise
1272
+ rescue LocalValidationError, KeyError, TypeError, ArgumentError
1273
+ fail_local(BridgeErrorCode::LOCAL_PLAN_INVALID)
1274
+ rescue StandardError
1275
+ fail_local(BridgeErrorCode::LOCAL_PLAN_INVALID)
1276
+ end
1277
+ end
1278
+
1279
+ plan = {
1280
+ "planKind" => "mayan-swift-v2-local-source-swap",
1281
+ "providerId" => "mayan-swift-v2",
1282
+ "capabilityId" => route.capability_id,
1283
+ "sourceChainId" => route.source_chain_id,
1284
+ "destinationChainId" => route.destination_chain_id,
1285
+ "sourceTokenDeploymentId" => route.source_token_deployment_id,
1286
+ "destinationTokenDeploymentId" => route.destination_token_deployment_id,
1287
+ "quoteId" => quote.fetch("quoteId"),
1288
+ "rawQuoteSha256" => raw_sha,
1289
+ "orderNonce" => nonce,
1290
+ "swapperAddress" => swapper,
1291
+ "destinationAddress" => destination,
1292
+ "orderHash" => order_hash,
1293
+ "quoteBindingHash" => binding,
1294
+ "minimumIntermediateAmount" => parsed_quote.minimum_intermediate_amount,
1295
+ "sourceSwap" => source_swap,
1296
+ "planHash" => plan_hash(binding, source_swap)
1297
+ }
1298
+ deep_freeze(plan)
1299
+ rescue BridgeError
1300
+ raise
1301
+ rescue StandardError
1302
+ fail_local(BridgeErrorCode::LOCAL_PLAN_INVALID)
1303
+ end
1304
+
1305
+ def rpc_endpoint(client, route)
1306
+ config = local_build_config(client)
1307
+ key, camel = if route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID
1308
+ [:ethereum_rpc, "ethereumRpc"]
1309
+ else
1310
+ [:solana_rpc, "solanaRpc"]
1311
+ end
1312
+ value = config_value(config, key.to_s, camel)
1313
+ fail_local(BridgeErrorCode::LOCAL_RPC_REQUIRED) if value == :__bridge_missing__ || value.nil?
1314
+ return value if value.is_a?(RpcEndpointConfig)
1315
+ local_invalid! unless value.is_a?(Hash)
1316
+ local_invalid! unless value.keys.all? { |item| item.is_a?(String) || item.is_a?(Symbol) }
1317
+ allowed = %w[http_url httpUrl headers]
1318
+ local_invalid! unless value.keys.all? { |item| allowed.include?(item.to_s) }
1319
+ http_url = config_value(value, "http_url", "httpUrl")
1320
+ local_invalid! if http_url == :__bridge_missing__
1321
+ headers = config_value(value, "headers")
1322
+ headers = {} if headers == :__bridge_missing__
1323
+ local_invalid! unless headers.is_a?(Hash)
1324
+ RpcEndpointConfig.new(http_url: http_url, headers: headers)
1325
+ rescue ConfigError, URI::InvalidURIError, ArgumentError, TypeError
1326
+ local_invalid!
1327
+ end
1328
+
1329
+ def rpc_body(method, params)
1330
+ JSON.generate("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => params)
1331
+ end
1332
+
1333
+ def rpc_request(client, endpoint, method, params, options)
1334
+ check_aborted(client, options)
1335
+ body = rpc_body(method, params)
1336
+ headers = endpoint.headers.each_with_object({}) { |(name, value), result| result[name.to_s] = value }
1337
+ headers["accept"] = "application/json"
1338
+ headers["content-type"] = "application/json"
1339
+ deadline = local_deadline(client.config.timeout)
1340
+ response = nil
1341
+ begin
1342
+ response = perform_local_adapter_request(
1343
+ client, options, deadline,
1344
+ method: :post,
1345
+ url: endpoint.http_url,
1346
+ headers: headers,
1347
+ body: body,
1348
+ timeout: local_remaining(deadline)
1349
+ )
1350
+ check_aborted(client, options)
1351
+ status = response_status(response)
1352
+ fail_local(BridgeErrorCode::SOURCE_RPC_TRANSPORT) unless status.is_a?(Integer) && status.between?(200, 299)
1353
+ fail_local(BridgeErrorCode::SOURCE_RPC_INVALID_RESPONSE) if response_declared_size(response).to_i > MAX_RESPONSE_BYTES
1354
+ text = local_with_deadline(deadline) do
1355
+ read_local_response_body(response, client, options, deadline)
1356
+ end
1357
+ root = begin
1358
+ StrictJsonParser.new(text).parse
1359
+ rescue BridgeError
1360
+ fail_local(BridgeErrorCode::SOURCE_RPC_INVALID_RESPONSE)
1361
+ end
1362
+ local_invalid! unless root.object_entries && root.value.is_a?(Hash)
1363
+ version = node_value(root, "jsonrpc")
1364
+ local_invalid! unless version == "2.0"
1365
+ local_invalid! if root.object_entries.key?("version")
1366
+ identifier = node_value(root, "id")
1367
+ local_invalid! unless identifier.is_a?(Integer) && identifier == 1
1368
+ local_invalid! if root.object_entries.key?("error") || !root.object_entries.key?("result")
1369
+ node_value(root, "result")
1370
+ rescue BridgeError
1371
+ dispose_local_response(response)
1372
+ raise
1373
+ rescue MayanBridgeBodyTooLarge
1374
+ dispose_local_response(response)
1375
+ fail_local(BridgeErrorCode::SOURCE_RPC_INVALID_RESPONSE)
1376
+ rescue TimeoutError, Timeout::Error
1377
+ dispose_local_response(response)
1378
+ fail_local(BridgeErrorCode::TIMEOUT)
1379
+ rescue LocalValidationError, LocalResponseTooLarge, LocalResponseInvalidEncoding
1380
+ dispose_local_response(response)
1381
+ fail_local(BridgeErrorCode::SOURCE_RPC_INVALID_RESPONSE)
1382
+ rescue StandardError
1383
+ dispose_local_response(response)
1384
+ check_aborted(client, options)
1385
+ fail_local(BridgeErrorCode::SOURCE_RPC_TRANSPORT)
1386
+ end
1387
+ end
1388
+
1389
+ def parse_rpc_account(value)
1390
+ local_invalid! unless value.is_a?(Hash)
1391
+ data = value["data"]
1392
+ owner = value["owner"]
1393
+ local_invalid! unless value["executable"] == false && data.is_a?(Array) && data.length == 2 && data[0].is_a?(String) && data[1] == "base64" && owner.is_a?(String)
1394
+ [base64_decode(data[0], 16_384), canonical_solana_address(owner)]
1395
+ end
1396
+
1397
+ def decode_lookup_table(address, data, owner, account_context_slot)
1398
+ local_invalid! unless owner == SOLANA_ADDRESS_LOOKUP_TABLE_OWNER && data.bytesize >= 56 && ((data.bytesize - 56) % 32).zero?
1399
+ local_invalid! unless data.byteslice(0, 4) == "\x01\x00\x00\x00".b
1400
+ local_invalid! unless data.byteslice(4, 8).unpack1("Q<") == UINT64_MAX
1401
+ last_extended_slot = data.byteslice(12, 8).unpack1("Q<")
1402
+ last_extended_start_index = data.getbyte(20)
1403
+ authority_option = data.getbyte(21)
1404
+ address_count = (data.bytesize - 56) / 32
1405
+ local_invalid! if authority_option > 1 || data.byteslice(54, 2) != "\0\0".b
1406
+ local_invalid! if authority_option.zero? && data.byteslice(22, 32) != "\0" * 32
1407
+ local_invalid! if address_count > MAX_LOOKUP_TABLE_ADDRESSES || last_extended_slot > account_context_slot || last_extended_start_index > address_count
1408
+ active_count = last_extended_slot == account_context_slot ? last_extended_start_index : address_count
1409
+ addresses = (56...(56 + active_count * 32)).step(32).map { |offset| base58_encode(data.byteslice(offset, 32)) }
1410
+ LookupTable.new(address: address, addresses: addresses, data: data)
1411
+ end
1412
+
1413
+ def fetch_source_rpc(client, route, plan, options)
1414
+ begin
1415
+ endpoint = rpc_endpoint(client, route)
1416
+ if route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID
1417
+ chain_id = rpc_request(client, endpoint, "eth_chainId", [], options)
1418
+ local_invalid! unless chain_id.is_a?(String) && chain_id.downcase == "0x1"
1419
+ addresses = [MayanSwiftV2BridgeClient::ETHEREUM_FORWARDER, route.swift_contract]
1420
+ source_swap = plan.fetch("sourceSwap")
1421
+ addresses << source_swap["routerAddress"] if source_swap.is_a?(Hash) && source_swap["kind"] == "evm-router"
1422
+ code = addresses.map do |address|
1423
+ bytecode = rpc_request(client, endpoint, "eth_getCode", [address, "latest"], options)
1424
+ local_invalid! unless bytecode.is_a?(String) && HEX_BYTES.match?(bytecode) && bytecode != "0x"
1425
+ { "address" => normalize_evm_address(address), "keccak256" => "0x#{keccak_hex(hex_to_bytes(bytecode))}" }
1426
+ end
1427
+ return SourceRpcResult.new(evidence: { "kind" => "evm", "rpcChainId" => "0x1", "code" => code }, lookup_tables: [], recent_blockhash: nil)
1428
+ end
1429
+
1430
+ genesis_hash = rpc_request(client, endpoint, "getGenesisHash", [], options)
1431
+ local_invalid! unless genesis_hash == SOLANA_MAINNET_GENESIS_HASH
1432
+ blockhash_result = rpc_request(client, endpoint, "getLatestBlockhash", [{ "commitment" => "confirmed" }], options)
1433
+ local_invalid! unless blockhash_result.is_a?(Hash)
1434
+ blockhash_context = blockhash_result["context"]
1435
+ blockhash_value = blockhash_result["value"]
1436
+ local_invalid! unless blockhash_context.is_a?(Hash) && blockhash_value.is_a?(Hash)
1437
+ blockhash_slot = blockhash_context["slot"]
1438
+ recent_blockhash = blockhash_value["blockhash"]
1439
+ last_valid = blockhash_value["lastValidBlockHeight"]
1440
+ local_invalid! unless blockhash_slot.is_a?(Integer) && blockhash_slot >= 0 && blockhash_slot <= MAX_SAFE_INTEGER &&
1441
+ recent_blockhash.is_a?(String) && last_valid.is_a?(Integer) && last_valid >= 0 && last_valid <= MAX_SAFE_INTEGER
1442
+ canonical_solana_address(recent_blockhash)
1443
+ source_swap = plan.fetch("sourceSwap")
1444
+ provider_alts = source_swap.is_a?(Hash) && source_swap["kind"] == "solana-jupiter-v6" ? source_swap.fetch("addressLookupTableAddresses") : []
1445
+ alt_addresses = [SOLANA_MAYAN_LOOKUP_TABLE, *provider_alts].each_with_object([]) { |address, out| out << address unless out.include?(address) }
1446
+ local_invalid! if alt_addresses.length > MAX_LOOKUP_TABLES
1447
+ accounts_result = rpc_request(client, endpoint, "getMultipleAccounts", [alt_addresses, { "encoding" => "base64", "commitment" => "confirmed", "minContextSlot" => blockhash_slot }], options)
1448
+ local_invalid! unless accounts_result.is_a?(Hash)
1449
+ account_context = accounts_result["context"]
1450
+ account_values = accounts_result["value"]
1451
+ local_invalid! unless account_context.is_a?(Hash) && account_values.is_a?(Array) && account_context["slot"].is_a?(Integer) &&
1452
+ account_context["slot"] >= blockhash_slot && account_context["slot"] <= MAX_SAFE_INTEGER && account_values.length == alt_addresses.length
1453
+ lookup_tables = alt_addresses.each_with_index.map do |address, index|
1454
+ data, owner = parse_rpc_account(account_values[index])
1455
+ decode_lookup_table(address, data, owner, account_context["slot"])
1456
+ end
1457
+ SourceRpcResult.new(
1458
+ evidence: {
1459
+ "kind" => "solana", "genesisHash" => genesis_hash,
1460
+ "blockhashContextSlot" => blockhash_slot.to_s, "accountContextSlot" => account_context["slot"].to_s,
1461
+ "recentBlockhash" => recent_blockhash, "lastValidBlockHeight" => last_valid.to_s,
1462
+ "lookupTables" => lookup_tables.map { |table| { "address" => table.address, "dataSha256" => sha256_hex(table.data) } }
1463
+ }, lookup_tables: lookup_tables, recent_blockhash: recent_blockhash
1464
+ )
1465
+ rescue BridgeError
1466
+ raise
1467
+ rescue LocalValidationError
1468
+ fail_local(BridgeErrorCode::SOURCE_RPC_INVALID_RESPONSE)
1469
+ rescue StandardError
1470
+ fail_local(BridgeErrorCode::SOURCE_RPC_INVALID_RESPONSE)
1471
+ end
1472
+ end
1473
+
1474
+ def wrap_in_cpi_proxy(instruction)
1475
+ {
1476
+ "programId" => SOLANA_CPI_PROXY_PROGRAM,
1477
+ "accounts" => [{ "pubkey" => instruction["programId"], "isSigner" => false, "isWritable" => false }, *instruction["accounts"]],
1478
+ "dataBase64" => instruction["dataBase64"]
1479
+ }
1480
+ end
1481
+
1482
+ def make_ata_instruction(swapper_address, owner, mint, allow_owner_off_curve)
1483
+ {
1484
+ "programId" => SOLANA_ASSOCIATED_TOKEN_PROGRAM,
1485
+ "accounts" => [
1486
+ { "pubkey" => swapper_address, "isSigner" => true, "isWritable" => true },
1487
+ { "pubkey" => associated_token_address(owner, mint, allow_owner_off_curve), "isSigner" => false, "isWritable" => true },
1488
+ { "pubkey" => owner, "isSigner" => false, "isWritable" => false },
1489
+ { "pubkey" => mint, "isSigner" => false, "isWritable" => false },
1490
+ { "pubkey" => SOLANA_SYSTEM_PROGRAM, "isSigner" => false, "isWritable" => false },
1491
+ { "pubkey" => SOLANA_TOKEN_PROGRAM, "isSigner" => false, "isWritable" => false },
1492
+ { "pubkey" => SOLANA_SYSVAR_RENT, "isSigner" => false, "isWritable" => false }
1493
+ ],
1494
+ "dataBase64" => "AQ=="
1495
+ }
1496
+ end
1497
+
1498
+ def make_spl_transfer_instruction(source, destination, owner, amount)
1499
+ data = [3].pack("C") + write_uint64_le(amount)
1500
+ {
1501
+ "programId" => SOLANA_TOKEN_PROGRAM,
1502
+ "accounts" => [
1503
+ { "pubkey" => source, "isSigner" => false, "isWritable" => true },
1504
+ { "pubkey" => destination, "isSigner" => false, "isWritable" => true },
1505
+ { "pubkey" => owner, "isSigner" => true, "isWritable" => false }
1506
+ ],
1507
+ "dataBase64" => Base64.strict_encode64(data)
1508
+ }
1509
+ end
1510
+
1511
+ def make_compute_unit_price_instruction(micro_lamports)
1512
+ data = [3].pack("C") + write_uint64_le(micro_lamports)
1513
+ { "programId" => SOLANA_COMPUTE_BUDGET_PROGRAM, "accounts" => [], "dataBase64" => Base64.strict_encode64(data) }
1514
+ end
1515
+
1516
+ def make_swift_init_instruction(quote, route, swapper_address, destination_address, state_address, state_token_account, minimum_intermediate_amount, cancel_fee, refund_fee, submit_fee, mode, order_nonce)
1517
+ relayer_account = associated_token_address(swapper_address, route.source_usdc_address, false)
1518
+ data = "\0".b * 198
1519
+ data[0, 8] = hex_to_bytes("0x#{SOLANA_INIT_ORDER_DISCRIMINATOR}")
1520
+ data[8, 8] = write_uint64_le(uint64(minimum_intermediate_amount, positive: true))
1521
+ data.setbyte(16, 0)
1522
+ data[17, 8] = write_uint64_le(submit_fee)
1523
+ data[25, 32] = native_address_bytes(destination_address, route.destination_chain_id)
1524
+ data[57, 2] = write_uint16_le(route.destination_wormhole_chain_id)
1525
+ data[59, 32] = native_address_bytes(route.destination_token_address, route.destination_chain_id)
1526
+ data[91, 8] = write_uint64_le(uint64(quote.fetch("minimumAmountOut"), positive: true))
1527
+ data[99, 8] = write_uint64_le(0)
1528
+ data[107, 8] = write_uint64_le(cancel_fee)
1529
+ data[115, 8] = write_uint64_le(refund_fee)
1530
+ data[123, 8] = write_uint64_le(uint64(quote.fetch("deadline"), positive: true))
1531
+ data[131, 32] = zero32
1532
+ data.setbyte(163, 0)
1533
+ data.setbyte(164, 0)
1534
+ data.setbyte(165, mode)
1535
+ data[166, 32] = swift_random(quote.fetch("quoteId"), order_nonce)
1536
+ {
1537
+ "programId" => SOLANA_SWIFT_PROGRAM,
1538
+ "accounts" => [
1539
+ { "pubkey" => swapper_address, "isSigner" => false, "isWritable" => false },
1540
+ { "pubkey" => swapper_address, "isSigner" => true, "isWritable" => true },
1541
+ { "pubkey" => state_address, "isSigner" => false, "isWritable" => true },
1542
+ { "pubkey" => state_token_account, "isSigner" => false, "isWritable" => true },
1543
+ { "pubkey" => relayer_account, "isSigner" => false, "isWritable" => true },
1544
+ { "pubkey" => SOLANA_SWIFT_PROGRAM, "isSigner" => false, "isWritable" => false },
1545
+ { "pubkey" => route.source_usdc_address, "isSigner" => false, "isWritable" => false },
1546
+ { "pubkey" => SOLANA_FEE_MANAGER_PROGRAM, "isSigner" => false, "isWritable" => false },
1547
+ { "pubkey" => SOLANA_TOKEN_PROGRAM, "isSigner" => false, "isWritable" => false },
1548
+ { "pubkey" => SOLANA_SYSTEM_PROGRAM, "isSigner" => false, "isWritable" => false }
1549
+ ],
1550
+ "dataBase64" => Base64.strict_encode64(data)
1551
+ }
1552
+ end
1553
+
1554
+ def encode_shortvec(value)
1555
+ local_invalid! unless value.is_a?(Integer) && value >= 0 && value <= 0xffff
1556
+ output = +"".b
1557
+ current = value
1558
+ loop do
1559
+ element = current & 0x7f
1560
+ current >>= 7
1561
+ element |= 0x80 if current.positive?
1562
+ output << element
1563
+ return output.b if current.zero?
1564
+ end
1565
+ end
1566
+
1567
+ def compile_solana_v0(payer, recent_blockhash, instructions, lookup_tables)
1568
+ key_meta = {}
1569
+ get_or_insert = lambda do |address|
1570
+ key_meta[address] ||= KeyMeta.new(signer: false, writable: false, invoked: false)
1571
+ end
1572
+ payer_meta = get_or_insert.call(payer)
1573
+ payer_meta.signer = true
1574
+ payer_meta.writable = true
1575
+ instructions.each do |instruction|
1576
+ get_or_insert.call(instruction["programId"]).invoked = true
1577
+ instruction["accounts"].each do |account|
1578
+ meta = get_or_insert.call(account["pubkey"])
1579
+ meta.signer ||= account["isSigner"]
1580
+ meta.writable ||= account["isWritable"]
1581
+ end
1582
+ end
1583
+
1584
+ lookup_table_lookups = []
1585
+ writable_lookup_keys = []
1586
+ readonly_lookup_keys = []
1587
+ lookup_tables.each do |table|
1588
+ writable_indexes = []
1589
+ readonly_indexes = []
1590
+ key_meta.to_a.each do |address, meta|
1591
+ next if meta.signer || meta.invoked || !meta.writable
1592
+ index = table.addresses.index(address)
1593
+ next if index.nil?
1594
+ local_invalid! if index > 255
1595
+ writable_indexes << index
1596
+ writable_lookup_keys << address
1597
+ key_meta.delete(address)
1598
+ end
1599
+ key_meta.to_a.each do |address, meta|
1600
+ next if meta.signer || meta.invoked || meta.writable
1601
+ index = table.addresses.index(address)
1602
+ next if index.nil?
1603
+ local_invalid! if index > 255
1604
+ readonly_indexes << index
1605
+ readonly_lookup_keys << address
1606
+ key_meta.delete(address)
1607
+ end
1608
+ lookup_table_lookups << [table.address, writable_indexes, readonly_indexes] if !writable_indexes.empty? || !readonly_indexes.empty?
1609
+ end
1610
+ local_invalid! if key_meta.length > 256 || lookup_table_lookups.length > MAX_LOOKUP_TABLES
1611
+ entries = key_meta.to_a
1612
+ writable_signers = entries.select { |_, meta| meta.signer && meta.writable }
1613
+ readonly_signers = entries.select { |_, meta| meta.signer && !meta.writable }
1614
+ writable_non_signers = entries.select { |_, meta| !meta.signer && meta.writable }
1615
+ readonly_non_signers = entries.select { |_, meta| !meta.signer && !meta.writable }
1616
+ local_invalid! if writable_signers.empty? || writable_signers.first.first != payer
1617
+ static_keys = [writable_signers, readonly_signers, writable_non_signers, readonly_non_signers].flat_map { |group| group.map(&:first) }
1618
+ all_keys = static_keys + writable_lookup_keys + readonly_lookup_keys
1619
+ local_invalid! if all_keys.length > 256
1620
+ key_indexes = all_keys.each_with_index.to_h
1621
+ compiled = instructions.map do |instruction|
1622
+ program_index = key_indexes[instruction["programId"]]
1623
+ local_invalid! if program_index.nil?
1624
+ account_indexes = instruction["accounts"].map do |account|
1625
+ index = key_indexes[account["pubkey"]]
1626
+ local_invalid! if index.nil?
1627
+ index
1628
+ end
1629
+ [program_index, account_indexes, encode_instruction_data(instruction["dataBase64"])]
1630
+ end
1631
+ compiled_bytes = encode_shortvec(compiled.length) + compiled.map do |program_index, account_indexes, data|
1632
+ [program_index].pack("C") + encode_shortvec(account_indexes.length) + account_indexes.pack("C*") + encode_shortvec(data.bytesize) + data
1633
+ end.join.b
1634
+ lookup_bytes = encode_shortvec(lookup_table_lookups.length) + lookup_table_lookups.map do |address, writable_indexes, readonly_indexes|
1635
+ base58_decode(address) + encode_shortvec(writable_indexes.length) + writable_indexes.pack("C*") + encode_shortvec(readonly_indexes.length) + readonly_indexes.pack("C*")
1636
+ end.join.b
1637
+ header = [writable_signers.length + readonly_signers.length, readonly_signers.length, readonly_non_signers.length].pack("C3")
1638
+ message = "\x80".b + header + encode_shortvec(static_keys.length) + static_keys.map { |address| base58_decode(address) }.join.b + base58_decode(canonical_solana_address(recent_blockhash)) + compiled_bytes + lookup_bytes
1639
+ required_signatures = writable_signers.length + readonly_signers.length
1640
+ transaction = encode_shortvec(required_signatures) + ("\0".b * (required_signatures * 64)) + message
1641
+ local_invalid! if transaction.bytesize > MAX_SOLANA_TRANSACTION_BYTES
1642
+ Base64.strict_encode64(transaction)
1643
+ end
1644
+
1645
+ def build_evm_order_call(quote, route, plan, swapper_address, destination_address, minimum_intermediate_amount, cancel_fee, refund_fee, mode, order_nonce)
1646
+ order_words = [
1647
+ word_uint(1),
1648
+ word_bytes32(native_address_bytes(swapper_address, route.source_chain_id)),
1649
+ word_bytes32(native_address_bytes(destination_address, route.destination_chain_id)),
1650
+ word_uint(route.destination_wormhole_chain_id),
1651
+ word_bytes32(zero32),
1652
+ word_bytes32(native_address_bytes(route.destination_token_address, route.destination_chain_id)),
1653
+ word_uint(uint64(quote.fetch("minimumAmountOut"), positive: true)),
1654
+ word_uint(0), word_uint(cancel_fee), word_uint(refund_fee),
1655
+ word_uint(uint64(quote.fetch("deadline"), positive: true)), word_uint(0), word_uint(mode),
1656
+ word_bytes32(swift_random(quote.fetch("quoteId"), order_nonce))
1657
+ ]
1658
+ swift_data = "\xa3\xa3\x08\x34".b + encode_abi_with_dynamics([
1659
+ word_address(route.source_usdc_address), word_uint(uint64(quote.fetch("amountIn"), positive: true)), *order_words
1660
+ ], ["".b])
1661
+ source_swap = plan.fetch("sourceSwap")
1662
+ if source_swap["kind"] == "none"
1663
+ return "0xe4269fc4#{encode_abi_with_dynamics([
1664
+ word_address(route.source_usdc_address), word_uint(uint64(quote.fetch("amountIn"), positive: true)),
1665
+ word_uint(0), word_uint(0), word_uint(0), word_bytes32(zero32), word_bytes32(zero32), word_address(route.swift_contract)
1666
+ ], [swift_data]).unpack1("H*")}"
1667
+ end
1668
+ local_invalid! unless source_swap["kind"] == "evm-router"
1669
+ router_data = hex_to_bytes(source_swap.fetch("calldata"))
1670
+ router_offset = 13 * 32
1671
+ swift_offset = router_offset + 32 + pad32(router_data).bytesize
1672
+ head = [
1673
+ word_address(route.source_token_address), word_uint(uint64(quote.fetch("amountIn"), positive: true)),
1674
+ word_uint(0), word_uint(0), word_uint(0), word_bytes32(zero32), word_bytes32(zero32), word_address(source_swap.fetch("routerAddress")),
1675
+ word_uint(router_offset), word_address(route.source_usdc_address), word_uint(uint64(minimum_intermediate_amount, positive: true)),
1676
+ word_address(route.swift_contract), word_uint(swift_offset)
1677
+ ]
1678
+ "0x30dedc57#{(head.join.b + encode_abi_bytes(router_data) + encode_abi_bytes(swift_data)).unpack1('H*')}"
1679
+ end
1680
+
1681
+ def build_solana_instructions(quote, route, plan, swapper_address, destination_address, minimum_intermediate_amount, cancel_fee, refund_fee, submit_fee, mode, order_nonce, suggested_priority_fee)
1682
+ order_hash = hash_order(quote, route, swapper_address, destination_address, order_nonce, cancel_fee, refund_fee, mode)
1683
+ state_address = find_program_address(["STATE_SOURCE".b, hex_to_bytes(order_hash), write_uint16_le(2)], SOLANA_SWIFT_PROGRAM).first
1684
+ state_token = associated_token_address(state_address, route.source_usdc_address, true)
1685
+ init = make_swift_init_instruction(quote, route, swapper_address, destination_address, state_address, state_token, minimum_intermediate_amount, cancel_fee, refund_fee, submit_fee, mode, order_nonce)
1686
+ if route.is_usdc
1687
+ instructions = []
1688
+ instructions << make_compute_unit_price_instruction(suggested_priority_fee) if suggested_priority_fee && suggested_priority_fee.positive?
1689
+ instructions << wrap_in_cpi_proxy(make_ata_instruction(swapper_address, state_address, route.source_usdc_address, true))
1690
+ instructions << wrap_in_cpi_proxy(make_spl_transfer_instruction(associated_token_address(swapper_address, route.source_usdc_address, false), state_token, swapper_address, uint64(quote.fetch("amountIn"), positive: true)))
1691
+ instructions << wrap_in_cpi_proxy(init)
1692
+ return instructions
1693
+ end
1694
+ source_swap = plan.fetch("sourceSwap")
1695
+ local_invalid! unless source_swap.is_a?(Hash) && source_swap["kind"] == "solana-jupiter-v6"
1696
+ provider_instructions = source_swap.fetch("instructions")
1697
+ local_invalid! unless provider_instructions.is_a?(Array)
1698
+ compute_count = provider_instructions.take_while { |instruction| instruction["programId"] == SOLANA_COMPUTE_BUDGET_PROGRAM }.length
1699
+ compute = provider_instructions.first(compute_count)
1700
+ remainder = provider_instructions.drop(compute_count)
1701
+ swap_indexes = remainder.each_index.select { |index| remainder[index]["programId"] == MayanSwiftV2BridgeClient::SOLANA_JUPITER_V6 }
1702
+ local_invalid! if swap_indexes.empty? || swap_indexes.first < 1
1703
+ swap_index = swap_indexes.first
1704
+ setup = remainder.first(swap_index)
1705
+ swap = remainder[swap_index]
1706
+ local_invalid! unless remainder[(swap_index + 1)..].to_a.empty?
1707
+ compute + setup.map { |instruction| wrap_in_cpi_proxy(instruction) } + [swap, wrap_in_cpi_proxy(init)]
1708
+ end
1709
+
1710
+ def exact_keys?(value, expected)
1711
+ value.is_a?(Hash) && value.keys.sort == expected.sort && value.length == expected.length
1712
+ end
1713
+
1714
+ def validate_plan(request, route, parsed_quote, plan, raw_quote_sha256, order_hash, binding, swapper_address, destination_address, build_code)
1715
+ expected_keys = %w[planKind providerId capabilityId sourceChainId destinationChainId sourceTokenDeploymentId destinationTokenDeploymentId quoteId rawQuoteSha256 orderNonce swapperAddress destinationAddress orderHash quoteBindingHash minimumIntermediateAmount sourceSwap planHash]
1716
+ local_invalid! unless exact_keys?(plan, expected_keys)
1717
+ quote = request.fetch("quote")
1718
+ local_invalid! unless quote.is_a?(Hash)
1719
+ expected = {
1720
+ "planKind" => "mayan-swift-v2-local-source-swap", "providerId" => "mayan-swift-v2", "capabilityId" => route.capability_id,
1721
+ "sourceChainId" => route.source_chain_id, "destinationChainId" => route.destination_chain_id,
1722
+ "sourceTokenDeploymentId" => route.source_token_deployment_id, "destinationTokenDeploymentId" => route.destination_token_deployment_id,
1723
+ "quoteId" => quote["quoteId"], "rawQuoteSha256" => raw_quote_sha256, "orderNonce" => request["orderNonce"],
1724
+ "swapperAddress" => swapper_address, "destinationAddress" => destination_address, "orderHash" => order_hash,
1725
+ "quoteBindingHash" => binding, "minimumIntermediateAmount" => parsed_quote.minimum_intermediate_amount
1726
+ }
1727
+ local_invalid! unless expected.all? { |key, value| plan[key] == value }
1728
+ source_swap = plan["sourceSwap"]
1729
+ local_invalid! unless source_swap.is_a?(Hash)
1730
+ normalized_source_swap = if route.is_usdc
1731
+ local_invalid! unless exact_keys?(source_swap, ["kind"]) && source_swap["kind"] == "none"
1732
+ { "kind" => "none" }
1733
+ else
1734
+ state_address = ""
1735
+ state_token = ""
1736
+ if route.source_chain_id == MayanSwiftV2BridgeClient::SOLANA_CHAIN_ID
1737
+ state_address = find_program_address(["STATE_SOURCE".b, hex_to_bytes(order_hash), write_uint16_le(2)], SOLANA_SWIFT_PROGRAM).first
1738
+ state_token = associated_token_address(state_address, route.source_usdc_address, true)
1739
+ end
1740
+ raw_source_text = source_swap["rawProviderSourceSwapJson"]
1741
+ local_invalid! unless raw_source_text.is_a?(String)
1742
+ _root, raw_source = local_json_object(raw_source_text)
1743
+ expected_source = validate_source_swap_envelope(quote, route, raw_source, state_address, state_token, swapper_address, parsed_quote.minimum_intermediate_amount)
1744
+ actual_blank = source_swap.reject { |key, _| %w[rawResponseSha256 rawProviderSourceSwapJson].include?(key) }
1745
+ expected_blank = expected_source.reject { |key, _| %w[rawResponseSha256 rawProviderSourceSwapJson].include?(key) }
1746
+ local_invalid! unless actual_blank == expected_blank
1747
+ local_invalid! unless source_swap["rawResponseSha256"] == sha256_hex(raw_source_text)
1748
+ source_swap
1749
+ end
1750
+ local_invalid! unless plan["planHash"] == plan_hash(binding, normalized_source_swap)
1751
+ deep_dup(plan)
1752
+ rescue BridgeError
1753
+ raise
1754
+ rescue StandardError
1755
+ fail_local(build_code)
1756
+ end
1757
+
1758
+ def local_build_dependencies(route)
1759
+ route.dependencies.reject { |dependency| dependency == "mayan-hosted-transaction-builder" } + [route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID ? "configured-ethereum-rpc" : "configured-solana-rpc"]
1760
+ end
1761
+
1762
+ def build_local_unsigned(client, request, options = nil)
1763
+ options ||= {}
1764
+ value = deep_dup(request)
1765
+ begin
1766
+ value = context_mapping(value, build: true)
1767
+ quote = value.fetch("quote")
1768
+ local_invalid! unless quote.is_a?(Hash)
1769
+ route = validate_local_route(quote, client)
1770
+ parsed_quote = parse_source_quote(quote, route)
1771
+ swapper = normalize_local_address(value.fetch("swapperAddress"), route.source_chain_id)
1772
+ destination = normalize_local_address(value.fetch("destinationAddress"), route.destination_chain_id)
1773
+ nonce = value.fetch("orderNonce")
1774
+ local_invalid! unless nonce.is_a?(String) && NONCE.match?(nonce)
1775
+ validate_destination_minimum_compatibility(quote.fetch("minimumAmountOut"), parsed_quote.root)
1776
+ validate_local_expiry(quote, client)
1777
+ raw_quote = quote.fetch("rawSignedQuoteJson")
1778
+ local_invalid! unless raw_quote.is_a?(String)
1779
+ raw_sha = sha256_hex(raw_quote)
1780
+ order_hash = hash_order(quote, route, swapper, destination, nonce, parsed_quote.cancel_fee, parsed_quote.refund_fee, parsed_quote.mode)
1781
+ binding = quote_binding_hash(route, quote, raw_sha, nonce, swapper, destination)
1782
+ plan = validate_plan(value, route, parsed_quote, value.fetch("sourceSwapPlan"), raw_sha, order_hash, binding, swapper, destination, BridgeErrorCode::LOCAL_BUILD_INVALID)
1783
+ rescue BridgeError
1784
+ raise
1785
+ rescue LocalValidationError, KeyError, TypeError, ArgumentError
1786
+ fail_local(BridgeErrorCode::LOCAL_BUILD_INVALID)
1787
+ rescue StandardError
1788
+ fail_local(BridgeErrorCode::LOCAL_BUILD_INVALID)
1789
+ end
1790
+
1791
+ rpc = fetch_source_rpc(client, route, plan, options)
1792
+ begin
1793
+ transaction = if route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID
1794
+ { "kind" => "evm-unsigned-transaction", "chainId" => MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID, "from" => swapper, "to" => MayanSwiftV2BridgeClient::ETHEREUM_FORWARDER, "data" => build_evm_order_call(quote, route, plan, swapper, destination, parsed_quote.minimum_intermediate_amount, parsed_quote.cancel_fee, parsed_quote.refund_fee, parsed_quote.mode, nonce), "value" => "0" }
1795
+ else
1796
+ local_invalid! if rpc.recent_blockhash.nil?
1797
+ instructions = build_solana_instructions(quote, route, plan, swapper, destination, parsed_quote.minimum_intermediate_amount, parsed_quote.cancel_fee, parsed_quote.refund_fee, parsed_quote.submit_fee, parsed_quote.mode, nonce, parsed_quote.suggested_priority_fee)
1798
+ { "kind" => "solana-v0-unsigned-transaction", "chainId" => MayanSwiftV2BridgeClient::SOLANA_CHAIN_ID, "feePayer" => swapper, "transactionBase64" => compile_solana_v0(swapper, rpc.recent_blockhash, instructions, rpc.lookup_tables) }
1799
+ end
1800
+ allowance = route.source_chain_id == MayanSwiftV2BridgeClient::ETHEREUM_CHAIN_ID ? { "tokenDeploymentId" => route.source_token_deployment_id, "tokenAddress" => route.source_token_address, "owner" => swapper, "spender" => MayanSwiftV2BridgeClient::ETHEREUM_FORWARDER, "requiredAmount" => quote.fetch("amountIn") } : nil
1801
+ {
1802
+ "buildKind" => "mayan-swift-v2-local-unsigned", "providerId" => "mayan-swift-v2", "capabilityId" => route.capability_id,
1803
+ "quote" => deep_dup(quote), "sourceChainId" => route.source_chain_id, "destinationChainId" => route.destination_chain_id,
1804
+ "sourceSwapPlan" => deep_dup(plan), "transaction" => transaction, "allowance" => allowance,
1805
+ "construction" => { "mode" => "local", "referenceCommit" => MAYAN_REFERENCE_COMMIT, "orderNonce" => nonce, "orderHash" => order_hash, "minimumIntermediateAmount" => parsed_quote.minimum_intermediate_amount, "effectiveDependencies" => local_build_dependencies(route), "sourceRpcEvidence" => rpc.evidence },
1806
+ "validation" => { "level" => "local-structural", "quoteSignatureLocallyVerified" => false, "planBindingLocallyVerified" => true, "transactionBytesLocallyConstructed" => true, "settlementLocallyVerified" => false }
1807
+ }
1808
+ rescue BridgeError
1809
+ raise
1810
+ rescue LocalValidationError, KeyError, TypeError, ArgumentError
1811
+ fail_local(BridgeErrorCode::LOCAL_BUILD_INVALID)
1812
+ rescue StandardError
1813
+ fail_local(BridgeErrorCode::LOCAL_BUILD_INVALID)
1814
+ end
1815
+ end
1816
+
1817
+ class << self
1818
+ alias prepareSourceSwap prepare_source_swap
1819
+ alias buildLocalUnsigned build_local_unsigned
1820
+ end
1821
+ end
1822
+
1823
+ class MayanSwiftV2BridgeClient
1824
+ def prepare_source_swap(context, options = nil, **keyword_options)
1825
+ MayanSwiftV2BridgeLocal.prepare_source_swap(self, context, options || keyword_options)
1826
+ end
1827
+
1828
+ alias prepareSourceSwap prepare_source_swap
1829
+
1830
+ def build_local_unsigned(request, options = nil, **keyword_options)
1831
+ MayanSwiftV2BridgeLocal.build_local_unsigned(self, request, options || keyword_options)
1832
+ end
1833
+
1834
+ alias buildLocalUnsigned build_local_unsigned
1835
+ end
1836
+ end