erpc-sdk 0.6.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,1367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "dex_catalog"
6
+ require_relative "generated/swap_execution_capabilities"
7
+
8
+ module ERPC
9
+ # Stable local failures raised by the RPC-only exact-input quote path.
10
+ # Transport, timeout, JSON-RPC, and adapter errors are allowed to pass
11
+ # through unchanged so callers retain their native error and cause.
12
+ class SwapQuoteError < Error
13
+ MESSAGES = {
14
+ "SWAP_INVALID_ARGUMENT" => "Swap request is invalid",
15
+ "SWAP_UNSUPPORTED_CHAIN" => "Swap chain is unsupported",
16
+ "SWAP_UNKNOWN_POOL" => "Swap pool is unknown",
17
+ "SWAP_CHAIN_MISMATCH" => "Swap chain does not match the selected records",
18
+ "SWAP_INVALID_POOL_STATE" => "Swap pool state is invalid",
19
+ "SWAP_UNKNOWN_TOKEN" => "Swap token is unknown",
20
+ "SWAP_TOKEN_NOT_ACTIVE" => "Swap token is not active",
21
+ "SWAP_UNSUPPORTED_TOKEN_STANDARD" => "Swap token standard is unsupported",
22
+ "SWAP_UNSUPPORTED_TOKEN" => "Swap token is unsupported for the selected pool",
23
+ "SWAP_POOL_TOKEN_MISMATCH" => "Swap pool tokens do not match the request",
24
+ "SWAP_UNSUPPORTED_ADAPTER" => "Swap adapter is unsupported",
25
+ "SWAP_PROGRAM_MISMATCH" => "Swap program does not match the selected records",
26
+ "SWAP_STATE_STALE" => "Swap pool state is stale",
27
+ "SWAP_INSUFFICIENT_LIQUIDITY" => "Swap pool liquidity is insufficient",
28
+ "SWAP_ARITHMETIC" => "Swap arithmetic overflowed or produced an invalid result"
29
+ }.freeze
30
+
31
+ attr_reader :code
32
+
33
+ def initialize(code)
34
+ @code = code.to_s.freeze
35
+ super(MESSAGES.fetch(@code, "Swap quote failed"))
36
+ end
37
+ end
38
+
39
+ # Stable machine-readable codes for unsigned swap preparation and RPC
40
+ # simulation. The prefixed aliases keep the shared identifiers convenient
41
+ # for callers that use the wire spelling as a Ruby constant.
42
+ module SwapExecutionErrorCode
43
+ INVALID_ARGUMENT = "SWAP_EXECUTION_INVALID_ARGUMENT"
44
+ UNSUPPORTED_EXECUTION = "SWAP_UNSUPPORTED_EXECUTION"
45
+ PROGRAM_MISMATCH = "SWAP_PROGRAM_MISMATCH"
46
+ INSUFFICIENT_ALLOWANCE = "SWAP_INSUFFICIENT_ALLOWANCE"
47
+ SIMULATION_REVERTED = "SWAP_SIMULATION_REVERTED"
48
+ INVALID_SIMULATION = "SWAP_INVALID_SIMULATION"
49
+
50
+ SWAP_EXECUTION_INVALID_ARGUMENT = INVALID_ARGUMENT
51
+ SWAP_UNSUPPORTED_EXECUTION = UNSUPPORTED_EXECUTION
52
+ SWAP_PROGRAM_MISMATCH = PROGRAM_MISMATCH
53
+ SWAP_INSUFFICIENT_ALLOWANCE = INSUFFICIENT_ALLOWANCE
54
+ SWAP_SIMULATION_REVERTED = SIMULATION_REVERTED
55
+ SWAP_INVALID_SIMULATION = INVALID_SIMULATION
56
+
57
+ ALL = [
58
+ INVALID_ARGUMENT,
59
+ UNSUPPORTED_EXECUTION,
60
+ PROGRAM_MISMATCH,
61
+ INSUFFICIENT_ALLOWANCE,
62
+ SIMULATION_REVERTED,
63
+ INVALID_SIMULATION
64
+ ].freeze
65
+ end
66
+
67
+ # Stable local failures raised by unsigned preparation and simulation.
68
+ # Quote, transport, timeout, cancellation, and non-revert JSON-RPC errors
69
+ # retain their existing native behavior.
70
+ class SwapExecutionError < Error
71
+ MESSAGES = {
72
+ SwapExecutionErrorCode::INVALID_ARGUMENT => "Swap execution request is invalid",
73
+ SwapExecutionErrorCode::UNSUPPORTED_EXECUTION => "Swap execution is unsupported for the selected records",
74
+ SwapExecutionErrorCode::PROGRAM_MISMATCH => "Swap program does not match the selected records",
75
+ SwapExecutionErrorCode::INSUFFICIENT_ALLOWANCE => "Swap allowance is insufficient",
76
+ SwapExecutionErrorCode::SIMULATION_REVERTED => "Swap simulation reverted",
77
+ SwapExecutionErrorCode::INVALID_SIMULATION => "Swap simulation result is invalid"
78
+ }.freeze
79
+
80
+ attr_reader :code
81
+
82
+ def initialize(code)
83
+ @code = code.to_s.freeze
84
+ super(MESSAGES.fetch(@code, "Swap execution failed"))
85
+ end
86
+ end
87
+
88
+ # Configured RPC-backed exact-input quote client.
89
+ class SwapClient
90
+ SUPPORTED_QUOTE_ADAPTER = "evm-constant-product-v2"
91
+ # Quote eligibility is a handwritten review boundary. Catalog growth may
92
+ # add lookup or ranking records without granting them RPC quote access.
93
+ SUPPORTED_QUOTE_CAPABILITIES = [
94
+ {
95
+ chain_id: "eip155:1",
96
+ dex_deployment_id: "dex-deployment-0001",
97
+ factory_address: "0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f",
98
+ pool_definition_id: "pool-0001",
99
+ pool_address: "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc",
100
+ token0_deployment_id: "deployment-0008",
101
+ token0_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
102
+ token0_decimals: 6,
103
+ token0_standard: "erc20",
104
+ token1_deployment_id: "deployment-0002",
105
+ token1_address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
106
+ token1_decimals: 18,
107
+ token1_standard: "erc20",
108
+ adapter_kind: SUPPORTED_QUOTE_ADAPTER,
109
+ fee_numerator: "3",
110
+ fee_denominator: "1000"
111
+ }.freeze,
112
+ {
113
+ chain_id: "eip155:43114",
114
+ dex_deployment_id: "dex-deployment-0002",
115
+ factory_address: "0x9ad6c38be94206ca50bb0d90783181662f0cfa10",
116
+ pool_definition_id: "pool-0002",
117
+ pool_address: "0xf4003f4efbe8691b60249e6afbd307abe7758adb",
118
+ token0_deployment_id: "deployment-0004",
119
+ token0_address: "0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7",
120
+ token0_decimals: 18,
121
+ token0_standard: "erc20",
122
+ token1_deployment_id: "deployment-0009",
123
+ token1_address: "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e",
124
+ token1_decimals: 6,
125
+ token1_standard: "erc20",
126
+ adapter_kind: SUPPORTED_QUOTE_ADAPTER,
127
+ fee_numerator: "3",
128
+ fee_denominator: "1000"
129
+ }.freeze
130
+ ].freeze
131
+ UINT256_MAX = (1 << 256) - 1
132
+ UINT256_DECIMAL_MAX_LENGTH = 78
133
+ UINT112_MAX = (1 << 112) - 1
134
+ UINT32_MAX = (1 << 32) - 1
135
+
136
+ FACTORY_GET_PAIR_SELECTOR = "0xe6a43905"
137
+ PAIR_FACTORY_SELECTOR = "0xc45a0155"
138
+ PAIR_TOKEN0_SELECTOR = "0x0dfe1681"
139
+ PAIR_TOKEN1_SELECTOR = "0xd21220a7"
140
+ PAIR_GET_RESERVES_SELECTOR = "0x0902f1ac"
141
+ ROUTER_FACTORY_SELECTOR = "0xc45a0155"
142
+ ROUTER_GET_AMOUNTS_OUT_SELECTOR = "0xd06ca61f"
143
+ ERC20_ALLOWANCE_SELECTOR = "0xdd62ed3e"
144
+
145
+ REQUEST_KEY_ALIASES = {
146
+ "chainId" => :chain_id,
147
+ :chainId => :chain_id,
148
+ "chain_id" => :chain_id,
149
+ :chain_id => :chain_id,
150
+ "poolDefinitionId" => :pool_definition_id,
151
+ :poolDefinitionId => :pool_definition_id,
152
+ "pool_definition_id" => :pool_definition_id,
153
+ :pool_definition_id => :pool_definition_id,
154
+ "inputTokenDeploymentId" => :input_token_deployment_id,
155
+ :inputTokenDeploymentId => :input_token_deployment_id,
156
+ "input_token_deployment_id" => :input_token_deployment_id,
157
+ :input_token_deployment_id => :input_token_deployment_id,
158
+ "outputTokenDeploymentId" => :output_token_deployment_id,
159
+ :outputTokenDeploymentId => :output_token_deployment_id,
160
+ "output_token_deployment_id" => :output_token_deployment_id,
161
+ :output_token_deployment_id => :output_token_deployment_id,
162
+ "amountIn" => :amount_in,
163
+ :amountIn => :amount_in,
164
+ "amount_in" => :amount_in,
165
+ :amount_in => :amount_in,
166
+ "freshness" => :freshness,
167
+ :freshness => :freshness
168
+ }.freeze
169
+
170
+ FRESHNESS_KEY_ALIASES = {
171
+ "maxBlockAgeSeconds" => :max_block_age_seconds,
172
+ :maxBlockAgeSeconds => :max_block_age_seconds,
173
+ "max_block_age_seconds" => :max_block_age_seconds,
174
+ :max_block_age_seconds => :max_block_age_seconds,
175
+ "maxBlockLag" => :max_block_lag,
176
+ :maxBlockLag => :max_block_lag,
177
+ "max_block_lag" => :max_block_lag,
178
+ :max_block_lag => :max_block_lag,
179
+ "maxClockSkewSeconds" => :max_clock_skew_seconds,
180
+ :maxClockSkewSeconds => :max_clock_skew_seconds,
181
+ "max_clock_skew_seconds" => :max_clock_skew_seconds,
182
+ :max_clock_skew_seconds => :max_clock_skew_seconds
183
+ }.freeze
184
+
185
+ EXECUTION_REQUEST_KEY_ALIASES = REQUEST_KEY_ALIASES.merge(
186
+ "sender" => :sender,
187
+ :sender => :sender,
188
+ "recipient" => :recipient,
189
+ :recipient => :recipient,
190
+ "slippageBps" => :slippage_bps,
191
+ :slippageBps => :slippage_bps,
192
+ "slippage_bps" => :slippage_bps,
193
+ :slippage_bps => :slippage_bps,
194
+ "deadline" => :deadline,
195
+ :deadline => :deadline
196
+ ).freeze
197
+
198
+ EXECUTION_UNSUPPORTED_QUOTE_CODES = %w[
199
+ SWAP_UNKNOWN_POOL
200
+ SWAP_UNSUPPORTED_ADAPTER
201
+ SWAP_UNSUPPORTED_TOKEN
202
+ SWAP_UNSUPPORTED_TOKEN_STANDARD
203
+ ].freeze
204
+
205
+ REQUIRED_REQUEST_KEYS = %i[
206
+ chain_id
207
+ pool_definition_id
208
+ input_token_deployment_id
209
+ output_token_deployment_id
210
+ amount_in
211
+ ].freeze
212
+
213
+ NormalizedFreshness = Struct.new(
214
+ :max_block_age_seconds,
215
+ :max_block_lag,
216
+ :max_clock_skew_seconds,
217
+ keyword_init: true
218
+ )
219
+ NormalizedRequest = Struct.new(
220
+ :chain_id,
221
+ :pool_definition_id,
222
+ :input_token_deployment_id,
223
+ :output_token_deployment_id,
224
+ :amount_in_text,
225
+ :amount_in,
226
+ :freshness,
227
+ :pool,
228
+ :dex,
229
+ :input,
230
+ :output,
231
+ keyword_init: true
232
+ )
233
+ BlockHeader = Struct.new(:number, :hash, :timestamp, keyword_init: true)
234
+ EvmState = Struct.new(
235
+ :initial,
236
+ :latest_after_reads,
237
+ :reserve0,
238
+ :reserve1,
239
+ keyword_init: true
240
+ )
241
+ CalculatedQuote = Struct.new(
242
+ :amount_out,
243
+ :fee_numerator,
244
+ :fee_denominator,
245
+ keyword_init: true
246
+ )
247
+ ExecutionRequestSnapshot = Struct.new(
248
+ :quote_request,
249
+ :sender,
250
+ :recipient,
251
+ :slippage_bps,
252
+ :deadline,
253
+ keyword_init: true
254
+ )
255
+ NormalizedExecutionRequest = Struct.new(
256
+ :normalized,
257
+ :sender,
258
+ :recipient,
259
+ :slippage_bps,
260
+ :deadline,
261
+ :deadline_value,
262
+ keyword_init: true
263
+ )
264
+ PreparedExecutionContext = Struct.new(
265
+ :normalized,
266
+ :capability,
267
+ :transport,
268
+ :state,
269
+ :quote,
270
+ :preparation,
271
+ keyword_init: true
272
+ )
273
+
274
+ # The execution capability rows are generated from the canonical
275
+ # registry. Runtime code only parses this immutable string; it never reads
276
+ # the registry or a file at runtime.
277
+ EXECUTION_CAPABILITIES = JSON.parse(SWAP_EXECUTION_CAPABILITIES_JSON).map do |row|
278
+ row.freeze
279
+ end.freeze
280
+
281
+ private_constant :REQUEST_KEY_ALIASES
282
+ private_constant :FRESHNESS_KEY_ALIASES
283
+ private_constant :EXECUTION_REQUEST_KEY_ALIASES
284
+ private_constant :EXECUTION_UNSUPPORTED_QUOTE_CODES
285
+ private_constant :REQUIRED_REQUEST_KEYS
286
+ private_constant :SUPPORTED_QUOTE_CAPABILITIES
287
+ private_constant :EXECUTION_CAPABILITIES
288
+ private_constant :NormalizedFreshness
289
+ private_constant :NormalizedRequest
290
+ private_constant :BlockHeader
291
+ private_constant :EvmState
292
+ private_constant :CalculatedQuote
293
+ private_constant :ExecutionRequestSnapshot
294
+ private_constant :NormalizedExecutionRequest
295
+ private_constant :PreparedExecutionContext
296
+
297
+ def initialize(ethereum_transport:, avalanche_transport:)
298
+ @ethereum_transport = ethereum_transport
299
+ @avalanche_transport = avalanche_transport
300
+ # Tests replace this private implementation slot with a fixed clock for
301
+ # fixture replay. There is no public time or clock bypass.
302
+ @clock = -> { Time.now.to_i }
303
+ end
304
+
305
+ # Execute an exact-input quote through the configured EVM transport.
306
+ # The request is normalized and copied before the first RPC call.
307
+ def quote_exact_input(request)
308
+ normalized = normalize_request(request)
309
+ unless normalized.pool.fetch(:adapter).fetch(:kind) == SUPPORTED_QUOTE_ADAPTER &&
310
+ normalized.dex.fetch(:adapter_kind) == SUPPORTED_QUOTE_ADAPTER
311
+ return domain_error("SWAP_UNSUPPORTED_ADAPTER")
312
+ end
313
+
314
+ transport = case normalized.chain_id
315
+ when DexChainIDs::ETHEREUM_MAINNET then @ethereum_transport
316
+ when DexChainIDs::AVALANCHE_C_MAINNET then @avalanche_transport
317
+ else return domain_error("SWAP_UNSUPPORTED_ADAPTER")
318
+ end
319
+
320
+ state = read_evm_state(transport, normalized)
321
+ assert_freshness(state.initial, state.latest_after_reads, normalized.freshness)
322
+ calculated = calculate_quote(normalized, state)
323
+ # Recheck at completion so a clock change while the buffered state is
324
+ # being decoded cannot turn an old quote into a fresh one.
325
+ assert_freshness(state.initial, state.latest_after_reads, normalized.freshness)
326
+ build_quote_result(normalized, state, calculated)
327
+ end
328
+
329
+ # Prepare an unsigned ERC-20-to-ERC-20 router transaction from a fresh
330
+ # local quote and router preflight. No allowance, signature, or send is
331
+ # produced by this method.
332
+ def prepare_exact_input_swap(request, options = nil, **keyword_options)
333
+ options = keyword_options unless keyword_options.empty?
334
+ execution = normalize_execution_request(request)
335
+ context = prepare_execution_context(execution, options)
336
+ context.preparation
337
+ end
338
+
339
+ # Simulate the prepared router call against the quote block. The caller's
340
+ # allowance is read first; an insufficient allowance never reaches the
341
+ # router simulation.
342
+ def simulate_exact_input_swap(request, options = nil, **keyword_options)
343
+ options = keyword_options unless keyword_options.empty?
344
+ execution = normalize_execution_request(request)
345
+ context = prepare_execution_context(execution, options)
346
+ simulate_execution_context(context, options)
347
+ end
348
+
349
+ private
350
+
351
+ def normalize_request(request)
352
+ return domain_error("SWAP_INVALID_ARGUMENT") unless request.is_a?(Hash)
353
+
354
+ values = {}
355
+ request.each do |key, value|
356
+ canonical = REQUEST_KEY_ALIASES[key]
357
+ return domain_error("SWAP_INVALID_ARGUMENT") unless canonical
358
+ return domain_error("SWAP_INVALID_ARGUMENT") if values.key?(canonical)
359
+
360
+ values[canonical] = value
361
+ end
362
+ return domain_error("SWAP_INVALID_ARGUMENT") unless REQUIRED_REQUEST_KEYS.all? { |key| values.key?(key) }
363
+ return domain_error("SWAP_INVALID_ARGUMENT") if values.key?(:freshness) && values[:freshness].nil?
364
+
365
+ chain_id = copy_request_string(values[:chain_id])
366
+ pool_definition_id = copy_request_string(values[:pool_definition_id])
367
+ input_token_deployment_id = copy_request_string(values[:input_token_deployment_id])
368
+ output_token_deployment_id = copy_request_string(values[:output_token_deployment_id])
369
+ amount_in_text = copy_request_string(values[:amount_in])
370
+ return domain_error("SWAP_INVALID_ARGUMENT") unless [
371
+ chain_id,
372
+ pool_definition_id,
373
+ input_token_deployment_id,
374
+ output_token_deployment_id,
375
+ amount_in_text
376
+ ].all?
377
+
378
+ [pool_definition_id, input_token_deployment_id, output_token_deployment_id].each do |identifier|
379
+ return domain_error("SWAP_INVALID_ARGUMENT") unless opaque_id?(identifier)
380
+ end
381
+ amount_in = parse_canonical_decimal(amount_in_text, positive: true, code: "SWAP_INVALID_ARGUMENT")
382
+ freshness = normalize_freshness(values[:freshness])
383
+
384
+ return domain_error("SWAP_UNSUPPORTED_CHAIN") unless known_chain_id?(chain_id)
385
+
386
+ pool = DexCatalog.get_pool_definition(pool_definition_id)
387
+ return domain_error("SWAP_UNKNOWN_POOL") unless pool
388
+ return domain_error("SWAP_CHAIN_MISMATCH") unless pool.fetch(:chain_id) == chain_id
389
+ return domain_error("SWAP_INVALID_POOL_STATE") unless pool.fetch(:status) == "active"
390
+
391
+ dex = DexCatalog.get_dex_deployment(pool.fetch(:dex_deployment_id))
392
+ return domain_error("SWAP_INVALID_POOL_STATE") unless dex && dex.fetch(:status) == "active"
393
+
394
+ input = TokenCatalog.get_token_deployment(input_token_deployment_id)
395
+ return domain_error("SWAP_UNKNOWN_TOKEN") unless input
396
+ output = TokenCatalog.get_token_deployment(output_token_deployment_id)
397
+ return domain_error("SWAP_UNKNOWN_TOKEN") unless output
398
+ return domain_error("SWAP_CHAIN_MISMATCH") unless input.fetch(:chain_id) == chain_id && output.fetch(:chain_id) == chain_id
399
+ return domain_error("SWAP_TOKEN_NOT_ACTIVE") unless input.fetch(:status) == "active" && output.fetch(:status) == "active"
400
+
401
+ [input, output].each do |token|
402
+ standard = token.fetch(:standard)
403
+ return domain_error("SWAP_UNSUPPORTED_TOKEN_STANDARD") unless %w[erc20 spl-token].include?(standard)
404
+ end
405
+
406
+ return domain_error("SWAP_POOL_TOKEN_MISMATCH") if input_token_deployment_id == output_token_deployment_id
407
+
408
+ requested_pair = [input_token_deployment_id, output_token_deployment_id].sort
409
+ pool_pair = [pool.fetch(:token0_deployment_id), pool.fetch(:token1_deployment_id)].sort
410
+ return domain_error("SWAP_POOL_TOKEN_MISMATCH") unless requested_pair == pool_pair
411
+
412
+ unless pool.fetch(:adapter).fetch(:kind) == SUPPORTED_QUOTE_ADAPTER &&
413
+ dex.fetch(:adapter_kind) == SUPPORTED_QUOTE_ADAPTER
414
+ return domain_error("SWAP_UNSUPPORTED_ADAPTER")
415
+ end
416
+ return domain_error("SWAP_UNSUPPORTED_TOKEN_STANDARD") unless input.fetch(:standard) == "erc20" && output.fetch(:standard) == "erc20"
417
+ return domain_error("SWAP_UNSUPPORTED_TOKEN") unless supported_quote_capability?(pool, dex, input, output)
418
+
419
+ NormalizedRequest.new(
420
+ chain_id: chain_id,
421
+ pool_definition_id: pool_definition_id,
422
+ input_token_deployment_id: input_token_deployment_id,
423
+ output_token_deployment_id: output_token_deployment_id,
424
+ amount_in_text: amount_in_text,
425
+ amount_in: amount_in,
426
+ freshness: freshness,
427
+ pool: pool,
428
+ dex: dex,
429
+ input: input,
430
+ output: output
431
+ )
432
+ end
433
+
434
+ def snapshot_execution_request(request)
435
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless request.is_a?(Hash)
436
+
437
+ values = {}
438
+ request.each do |key, value|
439
+ canonical = EXECUTION_REQUEST_KEY_ALIASES[key]
440
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless canonical
441
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) if values.key?(canonical)
442
+
443
+ values[canonical] = snapshot_request_value(value)
444
+ end
445
+
446
+ quote_request = {}
447
+ %i[chain_id pool_definition_id input_token_deployment_id output_token_deployment_id amount_in].each do |key|
448
+ quote_request[key] = values[key] if values.key?(key)
449
+ end
450
+ quote_request[:freshness] = values[:freshness] if values.key?(:freshness)
451
+
452
+ ExecutionRequestSnapshot.new(
453
+ quote_request: quote_request.freeze,
454
+ sender: values[:sender],
455
+ recipient: values[:recipient],
456
+ slippage_bps: values[:slippage_bps],
457
+ deadline: values[:deadline]
458
+ ).freeze
459
+ end
460
+
461
+ def snapshot_request_value(value)
462
+ case value
463
+ when String
464
+ value.dup.freeze
465
+ when Hash
466
+ value.each_with_object({}) do |(key, child), copy|
467
+ copy[snapshot_request_value(key)] = snapshot_request_value(child)
468
+ end.freeze
469
+ when Array
470
+ value.map { |child| snapshot_request_value(child) }.freeze
471
+ else
472
+ value
473
+ end
474
+ end
475
+
476
+ def normalize_execution_request(request)
477
+ snapshot = snapshot_execution_request(request)
478
+ sender = normalize_execution_address(snapshot.sender)
479
+ recipient = normalize_execution_address(snapshot.recipient)
480
+ slippage_bps = normalize_execution_slippage(snapshot.slippage_bps)
481
+ deadline_value = parse_execution_deadline(snapshot.deadline)
482
+
483
+ # Reject an already elapsed deadline before any RPC call. It is checked
484
+ # again against the quote block and at completion below.
485
+ initial_clock = execution_current_time
486
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) if deadline_value <= initial_clock
487
+
488
+ normalized = begin
489
+ normalize_request(snapshot.quote_request)
490
+ rescue SwapQuoteError => error
491
+ if EXECUTION_UNSUPPORTED_QUOTE_CODES.include?(error.code)
492
+ execution_domain_error(SwapExecutionErrorCode::UNSUPPORTED_EXECUTION)
493
+ end
494
+ raise
495
+ end
496
+
497
+ NormalizedExecutionRequest.new(
498
+ normalized: normalized,
499
+ sender: sender,
500
+ recipient: recipient,
501
+ slippage_bps: slippage_bps,
502
+ deadline: snapshot.deadline,
503
+ deadline_value: deadline_value
504
+ ).freeze
505
+ end
506
+
507
+ def normalize_execution_address(value)
508
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless evm_address?(value)
509
+
510
+ normalized = value.downcase
511
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) if normalized == "0x#{'0' * 40}"
512
+
513
+ normalized.freeze
514
+ end
515
+
516
+ def normalize_execution_slippage(value)
517
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless value.is_a?(Integer)
518
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless value.between?(0, 9_999)
519
+
520
+ value
521
+ end
522
+
523
+ def parse_execution_deadline(value)
524
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless value.is_a?(String) &&
525
+ value.length.between?(1, UINT256_DECIMAL_MAX_LENGTH) &&
526
+ value.match?(/\A[0-9]+\z/) &&
527
+ (value.length == 1 || value[0] != "0")
528
+
529
+ parsed = Integer(value, 10)
530
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) if parsed.zero? || parsed > UINT256_MAX
531
+
532
+ parsed
533
+ rescue ArgumentError
534
+ execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT)
535
+ end
536
+
537
+ def execution_current_time
538
+ value = @clock.call
539
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) unless value.is_a?(Integer) && value >= 0
540
+
541
+ value
542
+ end
543
+
544
+ def execution_capability_for(normalized)
545
+ capability = EXECUTION_CAPABILITIES.find do |row|
546
+ row.fetch("poolDefinitionId") == normalized.pool.fetch(:pool_definition_id)
547
+ end
548
+ return execution_domain_error(SwapExecutionErrorCode::UNSUPPORTED_EXECUTION) unless capability
549
+ return execution_domain_error(SwapExecutionErrorCode::UNSUPPORTED_EXECUTION) unless capability.fetch("status") == "active"
550
+
551
+ token0 = TokenCatalog.get_token_deployment(capability.fetch("token0DeploymentId"))
552
+ token1 = TokenCatalog.get_token_deployment(capability.fetch("token1DeploymentId"))
553
+ wrapped = TokenCatalog.get_token_deployment(capability.fetch("wrappedNativeTokenDeploymentId"))
554
+ native_wrap = DexCatalog::NATIVE_WRAP_DEFINITIONS.find do |definition|
555
+ definition.fetch(:chain_id) == capability.fetch("chainId") &&
556
+ definition.fetch(:wrapped_token_deployment_id) == capability.fetch("wrappedNativeTokenDeploymentId")
557
+ end
558
+
559
+ matches_capability_token = lambda do |token, deployment_key, address_key, standard_key|
560
+ token &&
561
+ token.fetch(:deployment_id) == capability.fetch(deployment_key) &&
562
+ token.fetch(:chain_id) == capability.fetch("chainId") &&
563
+ canonical_evm_address_equal?(token[:address], capability.fetch(address_key)) &&
564
+ token.fetch(:standard) == capability.fetch(standard_key) &&
565
+ token.fetch(:status) == "active"
566
+ end
567
+
568
+ input_matches = matches_capability_token.call(
569
+ normalized.input,
570
+ "token0DeploymentId",
571
+ "token0Address",
572
+ "token0Standard"
573
+ ) || matches_capability_token.call(
574
+ normalized.input,
575
+ "token1DeploymentId",
576
+ "token1Address",
577
+ "token1Standard"
578
+ )
579
+ output_matches = matches_capability_token.call(
580
+ normalized.output,
581
+ "token0DeploymentId",
582
+ "token0Address",
583
+ "token0Standard"
584
+ ) || matches_capability_token.call(
585
+ normalized.output,
586
+ "token1DeploymentId",
587
+ "token1Address",
588
+ "token1Standard"
589
+ )
590
+
591
+ pool = normalized.pool
592
+ dex = normalized.dex
593
+ pool_adapter = pool.fetch(:adapter)
594
+ capability_matches =
595
+ capability.fetch("chainId") == pool.fetch(:chain_id) &&
596
+ capability.fetch("dexDeploymentId") == pool.fetch(:dex_deployment_id) &&
597
+ capability.fetch("adapterKind") == SUPPORTED_QUOTE_ADAPTER &&
598
+ capability.fetch("functionKind") == "exact-input-erc20-to-erc20" &&
599
+ capability.fetch("functionSignature") == SWAP_EXECUTION_FUNCTION_SIGNATURE &&
600
+ capability.fetch("functionSelector") == SWAP_EXECUTION_FUNCTION_SELECTOR &&
601
+ dex.fetch(:status) == "active" &&
602
+ canonical_evm_address_equal?(dex[:program_address], capability.fetch("factoryAddress")) &&
603
+ dex.fetch(:adapter_kind) == capability.fetch("adapterKind") &&
604
+ pool.fetch(:status) == "active" &&
605
+ pool_adapter.fetch(:kind) == capability.fetch("adapterKind") &&
606
+ pool_adapter.fetch(:fee_numerator) == "3" &&
607
+ pool_adapter.fetch(:fee_denominator) == "1000" &&
608
+ pool.fetch(:token0_deployment_id) == capability.fetch("token0DeploymentId") &&
609
+ pool.fetch(:token1_deployment_id) == capability.fetch("token1DeploymentId") &&
610
+ input_matches && output_matches &&
611
+ token0 && token1 && wrapped &&
612
+ wrapped.fetch(:chain_id) == capability.fetch("chainId") &&
613
+ canonical_evm_address_equal?(wrapped[:address], capability.fetch("wrappedNativeTokenAddress")) &&
614
+ wrapped.fetch(:standard) == "erc20" &&
615
+ wrapped.fetch(:status) == "active" &&
616
+ native_wrap &&
617
+ native_wrap.fetch(:status) == "active" &&
618
+ native_wrap.fetch(:wrapped_token_deployment_id) == capability.fetch("wrappedNativeTokenDeploymentId")
619
+
620
+ return execution_domain_error(SwapExecutionErrorCode::UNSUPPORTED_EXECUTION) unless capability_matches
621
+
622
+ capability
623
+ end
624
+
625
+ def canonical_evm_address_equal?(left, right)
626
+ left.is_a?(String) && right.is_a?(String) && evm_address?(left) && evm_address?(right) && left.casecmp?(right)
627
+ end
628
+
629
+ def prepare_execution_context(execution, options)
630
+ check_execution_options(options)
631
+ normalized = execution.normalized
632
+ capability = execution_capability_for(normalized)
633
+ transport = execution_transport_for(normalized)
634
+
635
+ state = read_evm_state(transport, normalized)
636
+ assert_freshness(state.initial, state.latest_after_reads, normalized.freshness)
637
+ quote = build_quote_result(normalized, state, calculate_quote(normalized, state))
638
+ assert_execution_deadline(execution, state.initial.timestamp, execution_current_time)
639
+
640
+ input_address = execution_token_address(normalized.input)
641
+ output_address = execution_token_address(normalized.output)
642
+ selector = rpc_selector(state.initial.hash)
643
+ router_address = capability.fetch("routerAddress")
644
+
645
+ router_code_raw = rpc_request(
646
+ transport,
647
+ "eth_getCode",
648
+ [router_address, selector]
649
+ )
650
+ router_code = parse_execution_hex_bytes(router_code_raw)
651
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) if router_code.length <= 2
652
+
653
+ router_factory_raw = rpc_request(
654
+ transport,
655
+ "eth_call",
656
+ [abi_call(router_address, ROUTER_FACTORY_SELECTOR), selector]
657
+ )
658
+ router_factory = parse_execution_address_word(router_factory_raw)
659
+ return execution_domain_error(SwapExecutionErrorCode::PROGRAM_MISMATCH) unless router_factory == capability.fetch("factoryAddress")
660
+
661
+ wrapped_native_raw = rpc_request(
662
+ transport,
663
+ "eth_call",
664
+ [abi_call(router_address, capability.fetch("wrappedNativeFunctionSelector")), selector]
665
+ )
666
+ wrapped_native = parse_execution_address_word(wrapped_native_raw)
667
+ return execution_domain_error(SwapExecutionErrorCode::PROGRAM_MISMATCH) unless wrapped_native == capability.fetch("wrappedNativeTokenAddress")
668
+
669
+ amounts_out_raw = rpc_request(
670
+ transport,
671
+ "eth_call",
672
+ [
673
+ abi_call(
674
+ router_address,
675
+ execution_get_amounts_out_data(normalized.amount_in, input_address, output_address)
676
+ ),
677
+ selector
678
+ ]
679
+ )
680
+ amounts_out = parse_execution_uint_array_of_two(amounts_out_raw)
681
+ quote_amount_out = parse_decimal_quantity(quote.fetch("amountOut"), "SWAP_ARITHMETIC")
682
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless
683
+ amounts_out[0] == normalized.amount_in && amounts_out[1] == quote_amount_out
684
+
685
+ latest_after_router_reads = parse_block_header(
686
+ rpc_request(transport, "eth_getBlockByNumber", ["latest", false])
687
+ )
688
+ assert_freshness(
689
+ state.initial,
690
+ latest_after_router_reads,
691
+ normalized.freshness
692
+ )
693
+
694
+ preparation = build_execution_preparation(execution, capability, quote)
695
+ assert_execution_deadline(execution, state.initial.timestamp, execution_current_time)
696
+ PreparedExecutionContext.new(
697
+ normalized: execution,
698
+ capability: capability,
699
+ transport: transport,
700
+ state: state,
701
+ quote: quote,
702
+ preparation: preparation
703
+ ).freeze
704
+ end
705
+
706
+ def execution_transport_for(normalized)
707
+ case normalized.chain_id
708
+ when DexChainIDs::ETHEREUM_MAINNET
709
+ @ethereum_transport
710
+ when DexChainIDs::AVALANCHE_C_MAINNET
711
+ @avalanche_transport
712
+ else
713
+ execution_domain_error(SwapExecutionErrorCode::UNSUPPORTED_EXECUTION)
714
+ end
715
+ end
716
+
717
+ def execution_token_address(token)
718
+ address = token[:address]
719
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless evm_address?(address)
720
+
721
+ address.downcase.freeze
722
+ end
723
+
724
+ def encode_execution_address_argument(address)
725
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless evm_address?(address)
726
+
727
+ "0" * 24 + address[2..].downcase
728
+ end
729
+
730
+ def encode_execution_uint256_word(value)
731
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless value.is_a?(Integer) && value.between?(0, UINT256_MAX)
732
+
733
+ format("%064x", value)
734
+ end
735
+
736
+ def execution_get_amounts_out_data(amount_in, input_address, output_address)
737
+ ROUTER_GET_AMOUNTS_OUT_SELECTOR +
738
+ encode_execution_uint256_word(amount_in) +
739
+ encode_execution_uint256_word(0x40) +
740
+ encode_execution_uint256_word(2) +
741
+ encode_execution_address_argument(input_address) +
742
+ encode_execution_address_argument(output_address)
743
+ end
744
+
745
+ def execution_swap_data(amount_in, minimum_amount_out, input_address, output_address, recipient, deadline)
746
+ SWAP_EXECUTION_FUNCTION_SELECTOR +
747
+ encode_execution_uint256_word(amount_in) +
748
+ encode_execution_uint256_word(minimum_amount_out) +
749
+ encode_execution_uint256_word(0xa0) +
750
+ encode_execution_address_argument(recipient) +
751
+ encode_execution_uint256_word(deadline) +
752
+ encode_execution_uint256_word(2) +
753
+ encode_execution_address_argument(input_address) +
754
+ encode_execution_address_argument(output_address)
755
+ end
756
+
757
+ def build_execution_preparation(execution, capability, quote)
758
+ normalized = execution.normalized
759
+ input_address = execution_token_address(normalized.input)
760
+ output_address = execution_token_address(normalized.output)
761
+ quote_amount_out = parse_decimal_quantity(quote.fetch("amountOut"), "SWAP_ARITHMETIC")
762
+ minimum_amount_out = checked_uint256(
763
+ quote_amount_out * (10_000 - execution.slippage_bps)
764
+ ) / 10_000
765
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT) if minimum_amount_out.zero?
766
+
767
+ preparation = {
768
+ "preparationKind" => "evm-router-v2-exact-input",
769
+ "executionCapabilityId" => capability.fetch("swapExecutionCapabilityId"),
770
+ "executionCapabilityDigest" => SWAP_EXECUTION_CAPABILITIES_CONTENT_DIGEST,
771
+ "quote" => quote,
772
+ "minimumAmountOut" => minimum_amount_out.to_s,
773
+ "slippageBps" => execution.slippage_bps,
774
+ "deadline" => execution.deadline,
775
+ "recipient" => execution.recipient,
776
+ "path" => [
777
+ {
778
+ "tokenDeploymentId" => normalized.input.fetch(:deployment_id),
779
+ "address" => input_address,
780
+ "standard" => "erc20",
781
+ "representationKind" => normalized.input.fetch(:representation_kind)
782
+ },
783
+ {
784
+ "tokenDeploymentId" => normalized.output.fetch(:deployment_id),
785
+ "address" => output_address,
786
+ "standard" => "erc20",
787
+ "representationKind" => normalized.output.fetch(:representation_kind)
788
+ }
789
+ ],
790
+ "transaction" => {
791
+ "kind" => "evm-unsigned-transaction",
792
+ "chainId" => quote.fetch("chainId"),
793
+ "from" => execution.sender,
794
+ "to" => capability.fetch("routerAddress"),
795
+ "data" => execution_swap_data(
796
+ normalized.amount_in,
797
+ minimum_amount_out,
798
+ input_address,
799
+ output_address,
800
+ execution.recipient,
801
+ execution.deadline_value
802
+ ),
803
+ "value" => "0"
804
+ },
805
+ "allowance" => {
806
+ "tokenDeploymentId" => normalized.input.fetch(:deployment_id),
807
+ "tokenAddress" => input_address,
808
+ "owner" => execution.sender,
809
+ "spender" => capability.fetch("routerAddress"),
810
+ "requiredAmount" => quote.fetch("amountIn")
811
+ }
812
+ }
813
+ deep_freeze(preparation)
814
+ end
815
+
816
+ def assert_execution_deadline(execution, quote_timestamp, completion_clock)
817
+ return true if execution.deadline_value > quote_timestamp && execution.deadline_value > completion_clock
818
+
819
+ execution_domain_error(SwapExecutionErrorCode::INVALID_ARGUMENT)
820
+ end
821
+
822
+ def simulate_execution_context(context, options)
823
+ check_execution_options(options)
824
+ execution = context.normalized
825
+ capability = context.capability
826
+ transport = context.transport
827
+ state = context.state
828
+ preparation = context.preparation
829
+ selector = rpc_selector(state.initial.hash)
830
+ allowance_data = ERC20_ALLOWANCE_SELECTOR +
831
+ encode_execution_address_argument(execution.sender) +
832
+ encode_execution_address_argument(capability.fetch("routerAddress"))
833
+ allowance_raw = rpc_request(
834
+ transport,
835
+ "eth_call",
836
+ [abi_call(preparation.fetch("allowance").fetch("tokenAddress"), allowance_data), selector]
837
+ )
838
+ current_allowance = parse_execution_uint_word(
839
+ allowance_raw,
840
+ code: SwapExecutionErrorCode::INSUFFICIENT_ALLOWANCE
841
+ )
842
+ return execution_domain_error(SwapExecutionErrorCode::INSUFFICIENT_ALLOWANCE) if current_allowance < execution.normalized.amount_in
843
+
844
+ simulation_raw = begin
845
+ rpc_request(
846
+ transport,
847
+ "eth_call",
848
+ [
849
+ {
850
+ "from" => preparation.fetch("transaction").fetch("from"),
851
+ "to" => preparation.fetch("transaction").fetch("to"),
852
+ "data" => preparation.fetch("transaction").fetch("data"),
853
+ "value" => "0x0"
854
+ },
855
+ selector
856
+ ]
857
+ )
858
+ rescue JsonRpcError => error
859
+ return execution_domain_error(SwapExecutionErrorCode::SIMULATION_REVERTED) if recognized_execution_revert?(error)
860
+
861
+ raise
862
+ end
863
+
864
+ latest_after_simulation = parse_block_header(
865
+ rpc_request(transport, "eth_getBlockByNumber", ["latest", false])
866
+ )
867
+ assert_freshness(
868
+ state.initial,
869
+ latest_after_simulation,
870
+ execution.normalized.freshness
871
+ )
872
+
873
+ amounts = parse_execution_uint_array_of_two(simulation_raw)
874
+ quote_amount_out = parse_decimal_quantity(preparation.fetch("quote").fetch("amountOut"), "SWAP_ARITHMETIC")
875
+ minimum_amount_out = parse_decimal_quantity(preparation.fetch("minimumAmountOut"), "SWAP_ARITHMETIC")
876
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless
877
+ amounts[0] == execution.normalized.amount_in &&
878
+ amounts[1] == quote_amount_out &&
879
+ amounts[1] >= minimum_amount_out
880
+
881
+ assert_execution_deadline(execution, state.initial.timestamp, execution_current_time)
882
+ deep_freeze(
883
+ "simulationKind" => "evm-call",
884
+ "preparation" => preparation,
885
+ "snapshot" => preparation.fetch("quote").fetch("snapshot"),
886
+ "currentAllowance" => current_allowance.to_s,
887
+ "amounts" => amounts.map(&:to_s),
888
+ "amountOut" => amounts[1].to_s
889
+ )
890
+ end
891
+
892
+ def parse_execution_hex_bytes(value, expected_bytes: nil, code: SwapExecutionErrorCode::INVALID_SIMULATION)
893
+ return execution_domain_error(code) unless value.is_a?(String) && value.start_with?("0x")
894
+
895
+ hex = value[2..]
896
+ return execution_domain_error(code) unless hex && hex.match?(/\A[0-9a-fA-F]*\z/) && hex.length.even?
897
+ return execution_domain_error(code) if expected_bytes && hex.length != expected_bytes * 2
898
+
899
+ "0x#{hex.downcase}"
900
+ end
901
+
902
+ def parse_execution_address_word(value, code: SwapExecutionErrorCode::INVALID_SIMULATION)
903
+ parsed = parse_execution_hex_bytes(value, expected_bytes: 32, code: code)
904
+ word = parsed[2..]
905
+ return execution_domain_error(code) unless word[0, 24] == "0" * 24
906
+
907
+ "0x#{word[24, 40]}"
908
+ end
909
+
910
+ def parse_execution_uint_word(value, code: SwapExecutionErrorCode::INVALID_SIMULATION)
911
+ parsed = parse_execution_hex_bytes(value, expected_bytes: 32, code: code)
912
+ Integer(parsed[2..], 16)
913
+ rescue ArgumentError
914
+ execution_domain_error(code)
915
+ end
916
+
917
+ def parse_execution_uint_array_of_two(value)
918
+ parsed = parse_execution_hex_bytes(value)
919
+ payload = parsed[2..]
920
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless payload.length == 256
921
+
922
+ offset = Integer(payload[0, 64], 16)
923
+ length = Integer(payload[64, 64], 16)
924
+ return execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION) unless offset == 0x20 && length == 2
925
+
926
+ [
927
+ Integer(payload[128, 64], 16),
928
+ Integer(payload[192, 64], 16)
929
+ ].freeze
930
+ rescue ArgumentError, TypeError
931
+ execution_domain_error(SwapExecutionErrorCode::INVALID_SIMULATION)
932
+ end
933
+
934
+ def recognized_execution_revert?(error)
935
+ return false unless error.is_a?(JsonRpcError)
936
+ return false unless [-32_000, -32_015, -32_603, 3].include?(error.code)
937
+
938
+ message = error.message.to_s.downcase
939
+ message.include?("execution reverted") ||
940
+ message.include?("transaction reverted") ||
941
+ message.include?("vm execution error") ||
942
+ message.match?(/\Arevert(?:ed)?(?:\b|:)/)
943
+ end
944
+
945
+ def check_execution_options(options)
946
+ return if options.nil?
947
+
948
+ cancelled = if options.is_a?(Hash)
949
+ options[:cancelled] || options["cancelled"] || options[:aborted] || options["aborted"]
950
+ elsif options.respond_to?(:cancelled?)
951
+ options.cancelled?
952
+ elsif options.respond_to?(:aborted?)
953
+ options.aborted?
954
+ end
955
+ return unless cancelled
956
+
957
+ reason = if options.is_a?(Hash)
958
+ options[:reason] || options["reason"]
959
+ end
960
+ raise reason if reason.is_a?(Exception)
961
+
962
+ raise TransportError, "Swap operation was cancelled", cause: nil
963
+ end
964
+
965
+ def deep_freeze(value)
966
+ case value
967
+ when Hash
968
+ value.each { |key, child| deep_freeze(key); deep_freeze(child) }
969
+ when Array
970
+ value.each { |child| deep_freeze(child) }
971
+ end
972
+ value.freeze
973
+ end
974
+
975
+ def copy_request_string(value)
976
+ return nil unless value.is_a?(String)
977
+ return nil if value.empty? || value.strip != value
978
+
979
+ value.dup.freeze
980
+ end
981
+
982
+ def normalize_freshness(value)
983
+ values = {
984
+ max_block_age_seconds: 120,
985
+ max_block_lag: 3,
986
+ max_clock_skew_seconds: 5
987
+ }
988
+ return NormalizedFreshness.new(**values) if value.nil?
989
+ return domain_error("SWAP_INVALID_ARGUMENT") unless value.is_a?(Hash)
990
+
991
+ seen = {}
992
+ value.each do |key, child|
993
+ canonical = FRESHNESS_KEY_ALIASES[key]
994
+ return domain_error("SWAP_INVALID_ARGUMENT") unless canonical
995
+ return domain_error("SWAP_INVALID_ARGUMENT") if seen[canonical]
996
+ return domain_error("SWAP_INVALID_ARGUMENT") unless child.is_a?(Integer)
997
+
998
+ maximum = {
999
+ max_block_age_seconds: 86_400,
1000
+ max_block_lag: 1_024,
1001
+ max_clock_skew_seconds: 300
1002
+ }.fetch(canonical)
1003
+ return domain_error("SWAP_INVALID_ARGUMENT") unless child.between?(0, maximum)
1004
+
1005
+ seen[canonical] = true
1006
+ values[canonical] = child
1007
+ end
1008
+ NormalizedFreshness.new(**values)
1009
+ end
1010
+
1011
+ def known_chain_id?(chain_id)
1012
+ DexCatalog::KNOWN_CHAIN_IDS.include?(chain_id)
1013
+ end
1014
+
1015
+ def supported_quote_capability?(pool, dex, input, output)
1016
+ capability = SUPPORTED_QUOTE_CAPABILITIES.find do |value|
1017
+ value.fetch(:pool_definition_id) == pool.fetch(:pool_definition_id)
1018
+ end
1019
+ return false unless capability
1020
+
1021
+ input_asset = TokenCatalog.get_token_asset(input.fetch(:asset_id))
1022
+ output_asset = TokenCatalog.get_token_asset(output.fetch(:asset_id))
1023
+ return false if [input_asset, output_asset].any? do |asset|
1024
+ asset && asset.fetch(:representation_kind) == "unclassified"
1025
+ end
1026
+
1027
+ matches_token = lambda do |token, deployment_key, address_key, decimals_key, standard_key|
1028
+ token.fetch(:chain_id) == capability.fetch(:chain_id) &&
1029
+ token.fetch(:deployment_id) == capability.fetch(deployment_key) &&
1030
+ token.fetch(:address) == capability.fetch(address_key) &&
1031
+ token.fetch(:decimals) == capability.fetch(decimals_key) &&
1032
+ token.fetch(:standard) == capability.fetch(standard_key)
1033
+ end
1034
+
1035
+ pool.fetch(:chain_id) == capability.fetch(:chain_id) &&
1036
+ pool.fetch(:dex_deployment_id) == capability.fetch(:dex_deployment_id) &&
1037
+ pool.fetch(:address) == capability.fetch(:pool_address) &&
1038
+ pool.fetch(:token0_deployment_id) == capability.fetch(:token0_deployment_id) &&
1039
+ pool.fetch(:token1_deployment_id) == capability.fetch(:token1_deployment_id) &&
1040
+ dex.fetch(:chain_id) == capability.fetch(:chain_id) &&
1041
+ dex.fetch(:dex_deployment_id) == capability.fetch(:dex_deployment_id) &&
1042
+ dex.fetch(:program_address) == capability.fetch(:factory_address) &&
1043
+ dex.fetch(:adapter_kind) == capability.fetch(:adapter_kind) &&
1044
+ pool.fetch(:adapter).fetch(:kind) == capability.fetch(:adapter_kind) &&
1045
+ pool.fetch(:adapter).fetch(:fee_numerator) == capability.fetch(:fee_numerator) &&
1046
+ pool.fetch(:adapter).fetch(:fee_denominator) == capability.fetch(:fee_denominator) &&
1047
+ (matches_token.call(input, :token0_deployment_id, :token0_address, :token0_decimals, :token0_standard) ||
1048
+ matches_token.call(input, :token1_deployment_id, :token1_address, :token1_decimals, :token1_standard)) &&
1049
+ (matches_token.call(output, :token0_deployment_id, :token0_address, :token0_decimals, :token0_standard) ||
1050
+ matches_token.call(output, :token1_deployment_id, :token1_address, :token1_decimals, :token1_standard))
1051
+ end
1052
+
1053
+ def opaque_id?(value)
1054
+ value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/)
1055
+ end
1056
+
1057
+ def parse_canonical_decimal(value, positive:, code:)
1058
+ return domain_error(code) unless value.is_a?(String) &&
1059
+ value.length.between?(1, UINT256_DECIMAL_MAX_LENGTH) &&
1060
+ value.match?(/\A[0-9]+\z/) &&
1061
+ (value.length == 1 || value[0] != "0")
1062
+
1063
+ parsed = Integer(value, 10)
1064
+ return domain_error(code) if parsed > UINT256_MAX || (positive && parsed.zero?)
1065
+
1066
+ parsed
1067
+ rescue ArgumentError
1068
+ domain_error(code)
1069
+ end
1070
+
1071
+ def parse_decimal_quantity(value, code)
1072
+ return domain_error(code) unless value.is_a?(String) &&
1073
+ value.length.between?(1, UINT256_DECIMAL_MAX_LENGTH) &&
1074
+ value.match?(/\A[0-9]+\z/) &&
1075
+ (value.length == 1 || value[0] != "0")
1076
+
1077
+ parsed = Integer(value, 10)
1078
+ return domain_error(code) if parsed > UINT256_MAX
1079
+
1080
+ parsed
1081
+ rescue ArgumentError
1082
+ domain_error(code)
1083
+ end
1084
+
1085
+ def read_evm_state(transport, normalized)
1086
+ network_chain_id = parse_hex_quantity(
1087
+ rpc_request(transport, "eth_chainId", []),
1088
+ "SWAP_CHAIN_MISMATCH"
1089
+ )
1090
+ expected_chain_id = normalized.chain_id == DexChainIDs::ETHEREUM_MAINNET ? 1 : 43_114
1091
+ return domain_error("SWAP_CHAIN_MISMATCH") unless network_chain_id == expected_chain_id
1092
+
1093
+ initial = parse_block_header(rpc_request(transport, "eth_getBlockByNumber", ["latest", false]))
1094
+ selector = rpc_selector(initial.hash)
1095
+ factory_code_raw = rpc_request(
1096
+ transport,
1097
+ "eth_getCode",
1098
+ [normalized.dex.fetch(:program_address), selector]
1099
+ )
1100
+ pool_code_raw = rpc_request(
1101
+ transport,
1102
+ "eth_getCode",
1103
+ [normalized.pool.fetch(:address), selector]
1104
+ )
1105
+
1106
+ token0 = TokenCatalog.get_token_deployment(normalized.pool.fetch(:token0_deployment_id))
1107
+ token1 = TokenCatalog.get_token_deployment(normalized.pool.fetch(:token1_deployment_id))
1108
+ return domain_error("SWAP_INVALID_POOL_STATE") unless token0 && token1
1109
+ token0_address = token0[:address]
1110
+ token1_address = token1[:address]
1111
+ return domain_error("SWAP_INVALID_POOL_STATE") unless token0_address && token1_address
1112
+
1113
+ pair_data = FACTORY_GET_PAIR_SELECTOR + encode_address_argument(token0_address) + encode_address_argument(token1_address)
1114
+ factory_pair_raw = rpc_request(
1115
+ transport,
1116
+ "eth_call",
1117
+ [abi_call(normalized.dex.fetch(:program_address), pair_data), selector]
1118
+ )
1119
+ pair_factory_raw = rpc_request(
1120
+ transport,
1121
+ "eth_call",
1122
+ [abi_call(normalized.pool.fetch(:address), PAIR_FACTORY_SELECTOR), selector]
1123
+ )
1124
+ pair_token0_raw = rpc_request(
1125
+ transport,
1126
+ "eth_call",
1127
+ [abi_call(normalized.pool.fetch(:address), PAIR_TOKEN0_SELECTOR), selector]
1128
+ )
1129
+ pair_token1_raw = rpc_request(
1130
+ transport,
1131
+ "eth_call",
1132
+ [abi_call(normalized.pool.fetch(:address), PAIR_TOKEN1_SELECTOR), selector]
1133
+ )
1134
+ reserves_raw = rpc_request(
1135
+ transport,
1136
+ "eth_call",
1137
+ [abi_call(normalized.pool.fetch(:address), PAIR_GET_RESERVES_SELECTOR), selector]
1138
+ )
1139
+
1140
+ latest_after_reads = parse_block_header(rpc_request(transport, "eth_getBlockByNumber", ["latest", false]))
1141
+ snapshot_number = "0x#{initial.number.to_s(16)}"
1142
+ reread = parse_block_header(rpc_request(transport, "eth_getBlockByNumber", [snapshot_number, false]))
1143
+ if reread.number != initial.number || reread.hash != initial.hash || reread.timestamp != initial.timestamp
1144
+ return domain_error("SWAP_STATE_STALE")
1145
+ end
1146
+
1147
+ # Freshness is checked before decoding any buffered ABI response, so a
1148
+ # stale snapshot deterministically wins over malformed pool state.
1149
+ assert_freshness(initial, latest_after_reads, normalized.freshness)
1150
+
1151
+ factory_code = parse_hex_bytes(factory_code_raw, expected_bytes: nil, code: "SWAP_INVALID_POOL_STATE")
1152
+ return domain_error("SWAP_PROGRAM_MISMATCH") if factory_code.length <= 2
1153
+ pool_code = parse_hex_bytes(pool_code_raw, expected_bytes: nil, code: "SWAP_INVALID_POOL_STATE")
1154
+ return domain_error("SWAP_INVALID_POOL_STATE") if pool_code.length <= 2
1155
+
1156
+ factory_pair = parse_address_word(factory_pair_raw)
1157
+ return domain_error("SWAP_PROGRAM_MISMATCH") unless factory_pair.casecmp?(normalized.pool.fetch(:address))
1158
+ pair_factory = parse_address_word(pair_factory_raw)
1159
+ return domain_error("SWAP_PROGRAM_MISMATCH") unless pair_factory.casecmp?(normalized.dex.fetch(:program_address))
1160
+ pair_token0 = parse_address_word(pair_token0_raw)
1161
+ pair_token1 = parse_address_word(pair_token1_raw)
1162
+ return domain_error("SWAP_POOL_TOKEN_MISMATCH") unless pair_token0.casecmp?(token0_address) && pair_token1.casecmp?(token1_address)
1163
+
1164
+ reserve0, reserve1 = parse_reserves(reserves_raw)
1165
+ EvmState.new(
1166
+ initial: initial,
1167
+ latest_after_reads: latest_after_reads,
1168
+ reserve0: reserve0,
1169
+ reserve1: reserve1
1170
+ )
1171
+ end
1172
+
1173
+ def rpc_request(transport, method, params)
1174
+ transport.request(method, params)
1175
+ end
1176
+
1177
+ def parse_hex_quantity(value, code)
1178
+ return domain_error(code) unless value.is_a?(String) &&
1179
+ value.length.between?(3, 66) &&
1180
+ value.start_with?("0x") &&
1181
+ value[2..].match?(/\A[0-9a-fA-F]+\z/) &&
1182
+ (value.length == 3 || value[2] != "0")
1183
+
1184
+ parsed = Integer(value[2..], 16)
1185
+ return domain_error(code) if parsed > UINT256_MAX
1186
+
1187
+ parsed
1188
+ rescue ArgumentError
1189
+ domain_error(code)
1190
+ end
1191
+
1192
+ def parse_hex_bytes(value, expected_bytes:, code:)
1193
+ return domain_error(code) unless value.is_a?(String) && value.start_with?("0x")
1194
+ hex = value[2..]
1195
+ return domain_error(code) unless hex && hex.match?(/\A[0-9a-fA-F]*\z/) && hex.length.even?
1196
+ return domain_error(code) if expected_bytes && hex.length != expected_bytes * 2
1197
+
1198
+ "0x#{hex.downcase}"
1199
+ end
1200
+
1201
+ def parse_block_header(value)
1202
+ return domain_error("SWAP_STATE_STALE") unless value.is_a?(Hash)
1203
+
1204
+ number = parse_hex_quantity(value["number"] || value[:number], "SWAP_STATE_STALE")
1205
+ hash = parse_hex_bytes(value["hash"] || value[:hash], expected_bytes: 32, code: "SWAP_STATE_STALE")
1206
+ timestamp = parse_hex_quantity(value["timestamp"] || value[:timestamp], "SWAP_STATE_STALE")
1207
+ BlockHeader.new(number: number, hash: hash, timestamp: timestamp)
1208
+ end
1209
+
1210
+ def parse_address_word(value)
1211
+ parsed = parse_hex_bytes(value, expected_bytes: 32, code: "SWAP_INVALID_POOL_STATE")
1212
+ word = parsed[2..]
1213
+ return domain_error("SWAP_INVALID_POOL_STATE") unless word[0, 24] == "0" * 24
1214
+
1215
+ "0x#{word[24, 40]}"
1216
+ end
1217
+
1218
+ def parse_uint_word(word, maximum, code:)
1219
+ return domain_error(code) unless word.is_a?(String) && word.length == 64 && word.match?(/\A[0-9a-fA-F]{64}\z/)
1220
+
1221
+ parsed = Integer(word, 16)
1222
+ return domain_error(code) if parsed > maximum
1223
+
1224
+ parsed
1225
+ rescue ArgumentError
1226
+ domain_error(code)
1227
+ end
1228
+
1229
+ def parse_reserves(value)
1230
+ parsed = parse_hex_bytes(value, expected_bytes: 96, code: "SWAP_INVALID_POOL_STATE")
1231
+ payload = parsed[2..]
1232
+ reserve0 = parse_uint_word(payload[0, 64], UINT112_MAX, code: "SWAP_INVALID_POOL_STATE")
1233
+ reserve1 = parse_uint_word(payload[64, 64], UINT112_MAX, code: "SWAP_INVALID_POOL_STATE")
1234
+ parse_uint_word(payload[128, 64], UINT32_MAX, code: "SWAP_INVALID_POOL_STATE")
1235
+ [reserve0, reserve1]
1236
+ end
1237
+
1238
+ def rpc_selector(hash)
1239
+ { "blockHash" => hash, "requireCanonical" => true }
1240
+ end
1241
+
1242
+ def abi_call(to, data)
1243
+ { "to" => to, "data" => data }
1244
+ end
1245
+
1246
+ def encode_address_argument(address)
1247
+ return domain_error("SWAP_INVALID_POOL_STATE") unless evm_address?(address)
1248
+
1249
+ "0" * 24 + address[2..].downcase
1250
+ end
1251
+
1252
+ def evm_address?(address)
1253
+ address.is_a?(String) && address.match?(/\A0x[0-9A-Fa-f]{40}\z/)
1254
+ end
1255
+
1256
+ def current_time
1257
+ value = @clock.call
1258
+ return domain_error("SWAP_INVALID_ARGUMENT") unless value.is_a?(Integer) && value >= 0
1259
+
1260
+ value
1261
+ end
1262
+
1263
+ def assert_freshness(initial, latest_after_reads, freshness)
1264
+ now = current_time
1265
+ timestamps = [initial.timestamp, latest_after_reads.timestamp]
1266
+ timestamps.each do |timestamp|
1267
+ return domain_error("SWAP_STATE_STALE") if timestamp > now + freshness.max_clock_skew_seconds
1268
+ return domain_error("SWAP_STATE_STALE") if now > timestamp && now - timestamp > freshness.max_block_age_seconds
1269
+ end
1270
+
1271
+ return domain_error("SWAP_STATE_STALE") if latest_after_reads.number < initial.number
1272
+ return domain_error("SWAP_STATE_STALE") if latest_after_reads.number - initial.number > freshness.max_block_lag
1273
+ if latest_after_reads.number == initial.number
1274
+ return domain_error("SWAP_STATE_STALE") if latest_after_reads.hash != initial.hash
1275
+ elsif latest_after_reads.timestamp < initial.timestamp
1276
+ return domain_error("SWAP_STATE_STALE")
1277
+ end
1278
+ if latest_after_reads.number == initial.number && latest_after_reads.hash == initial.hash &&
1279
+ latest_after_reads.timestamp != initial.timestamp
1280
+ return domain_error("SWAP_STATE_STALE")
1281
+ end
1282
+ true
1283
+ end
1284
+
1285
+ def calculate_quote(normalized, state)
1286
+ fee_numerator = parse_decimal_quantity(
1287
+ normalized.pool.fetch(:adapter).fetch(:fee_numerator),
1288
+ "SWAP_ARITHMETIC"
1289
+ )
1290
+ fee_denominator = parse_decimal_quantity(
1291
+ normalized.pool.fetch(:adapter).fetch(:fee_denominator),
1292
+ "SWAP_ARITHMETIC"
1293
+ )
1294
+ return domain_error("SWAP_ARITHMETIC") if fee_denominator <= fee_numerator
1295
+
1296
+ token0 = TokenCatalog.get_token_deployment(normalized.pool.fetch(:token0_deployment_id))
1297
+ token0_address = token0 && token0[:address]
1298
+ input_address = normalized.input[:address]
1299
+ output_address = normalized.output[:address]
1300
+ return domain_error("SWAP_INVALID_POOL_STATE") unless token0_address && input_address && output_address
1301
+
1302
+ input_is_token0 = input_address.casecmp?(token0_address)
1303
+ output_is_token0 = output_address.casecmp?(token0_address)
1304
+ return domain_error("SWAP_INVALID_POOL_STATE") if input_is_token0 == output_is_token0
1305
+
1306
+ reserve_in, reserve_out = input_is_token0 ? [state.reserve0, state.reserve1] : [state.reserve1, state.reserve0]
1307
+ return domain_error("SWAP_INSUFFICIENT_LIQUIDITY") if reserve_in.zero? || reserve_out.zero?
1308
+
1309
+ fee_factor = fee_denominator - fee_numerator
1310
+ adjusted = checked_uint256(normalized.amount_in * fee_factor)
1311
+ reserve_product = checked_uint256(reserve_in * fee_denominator)
1312
+ denominator = checked_uint256(reserve_product + adjusted)
1313
+ return domain_error("SWAP_ARITHMETIC") if denominator.zero?
1314
+ numerator = checked_uint256(adjusted * reserve_out)
1315
+ amount_out = checked_uint256(numerator / denominator)
1316
+ return domain_error("SWAP_INSUFFICIENT_LIQUIDITY") if amount_out.zero? || amount_out > reserve_out
1317
+
1318
+ CalculatedQuote.new(
1319
+ amount_out: amount_out,
1320
+ fee_numerator: fee_numerator,
1321
+ fee_denominator: fee_denominator
1322
+ )
1323
+ end
1324
+
1325
+ def checked_uint256(value)
1326
+ return domain_error("SWAP_ARITHMETIC") if value > UINT256_MAX
1327
+
1328
+ value
1329
+ end
1330
+
1331
+ def build_quote_result(normalized, state, calculated)
1332
+ fee = {
1333
+ "numerator" => calculated.fee_numerator.to_s,
1334
+ "denominator" => calculated.fee_denominator.to_s
1335
+ }.freeze
1336
+ snapshot = {
1337
+ "kind" => "evm-block",
1338
+ "blockNumber" => state.initial.number.to_s,
1339
+ "blockHash" => state.initial.hash,
1340
+ "blockTimestamp" => state.initial.timestamp.to_s
1341
+ }.freeze
1342
+ {
1343
+ "quoteKind" => "exact-input",
1344
+ "chainId" => normalized.chain_id,
1345
+ "poolDefinitionId" => normalized.pool_definition_id,
1346
+ "dexDeploymentId" => normalized.dex.fetch(:dex_deployment_id),
1347
+ "adapterKind" => normalized.pool.fetch(:adapter).fetch(:kind),
1348
+ "inputTokenDeploymentId" => normalized.input_token_deployment_id,
1349
+ "outputTokenDeploymentId" => normalized.output_token_deployment_id,
1350
+ "amountIn" => normalized.amount_in.to_s,
1351
+ "amountOut" => calculated.amount_out.to_s,
1352
+ "fee" => fee,
1353
+ "snapshot" => snapshot,
1354
+ "tokenCatalogDigest" => TokenCatalog::TOKEN_CATALOG_CONTENT_DIGEST,
1355
+ "dexCatalogDigest" => DexCatalog::DEX_CATALOG_CONTENT_DIGEST
1356
+ }.freeze
1357
+ end
1358
+
1359
+ def domain_error(code)
1360
+ raise SwapQuoteError, code
1361
+ end
1362
+
1363
+ def execution_domain_error(code)
1364
+ raise SwapExecutionError, code
1365
+ end
1366
+ end
1367
+ end