erpc-sdk 0.6.0 → 0.7.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,688 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dex_catalog"
4
+
5
+ module ERPC
6
+ # Stable local failures raised by the RPC-only exact-input quote path.
7
+ # Transport, timeout, JSON-RPC, and adapter errors are allowed to pass
8
+ # through unchanged so callers retain their native error and cause.
9
+ class SwapQuoteError < Error
10
+ MESSAGES = {
11
+ "SWAP_INVALID_ARGUMENT" => "Swap request is invalid",
12
+ "SWAP_UNSUPPORTED_CHAIN" => "Swap chain is unsupported",
13
+ "SWAP_UNKNOWN_POOL" => "Swap pool is unknown",
14
+ "SWAP_CHAIN_MISMATCH" => "Swap chain does not match the selected records",
15
+ "SWAP_INVALID_POOL_STATE" => "Swap pool state is invalid",
16
+ "SWAP_UNKNOWN_TOKEN" => "Swap token is unknown",
17
+ "SWAP_TOKEN_NOT_ACTIVE" => "Swap token is not active",
18
+ "SWAP_UNSUPPORTED_TOKEN_STANDARD" => "Swap token standard is unsupported",
19
+ "SWAP_UNSUPPORTED_TOKEN" => "Swap token is unsupported for the selected pool",
20
+ "SWAP_POOL_TOKEN_MISMATCH" => "Swap pool tokens do not match the request",
21
+ "SWAP_UNSUPPORTED_ADAPTER" => "Swap adapter is unsupported",
22
+ "SWAP_PROGRAM_MISMATCH" => "Swap program does not match the selected records",
23
+ "SWAP_STATE_STALE" => "Swap pool state is stale",
24
+ "SWAP_INSUFFICIENT_LIQUIDITY" => "Swap pool liquidity is insufficient",
25
+ "SWAP_ARITHMETIC" => "Swap arithmetic overflowed or produced an invalid result"
26
+ }.freeze
27
+
28
+ attr_reader :code
29
+
30
+ def initialize(code)
31
+ @code = code.to_s.freeze
32
+ super(MESSAGES.fetch(@code, "Swap quote failed"))
33
+ end
34
+ end
35
+
36
+ # Configured RPC-backed exact-input quote client.
37
+ class SwapClient
38
+ SUPPORTED_QUOTE_ADAPTER = "evm-constant-product-v2"
39
+ # Quote eligibility is a handwritten review boundary. Catalog growth may
40
+ # add lookup or ranking records without granting them RPC quote access.
41
+ SUPPORTED_QUOTE_CAPABILITIES = [
42
+ {
43
+ chain_id: "eip155:1",
44
+ dex_deployment_id: "dex-deployment-0001",
45
+ factory_address: "0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f",
46
+ pool_definition_id: "pool-0001",
47
+ pool_address: "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc",
48
+ token0_deployment_id: "deployment-0008",
49
+ token0_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
50
+ token0_decimals: 6,
51
+ token0_standard: "erc20",
52
+ token1_deployment_id: "deployment-0002",
53
+ token1_address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
54
+ token1_decimals: 18,
55
+ token1_standard: "erc20",
56
+ adapter_kind: SUPPORTED_QUOTE_ADAPTER,
57
+ fee_numerator: "3",
58
+ fee_denominator: "1000"
59
+ }.freeze,
60
+ {
61
+ chain_id: "eip155:43114",
62
+ dex_deployment_id: "dex-deployment-0002",
63
+ factory_address: "0x9ad6c38be94206ca50bb0d90783181662f0cfa10",
64
+ pool_definition_id: "pool-0002",
65
+ pool_address: "0xf4003f4efbe8691b60249e6afbd307abe7758adb",
66
+ token0_deployment_id: "deployment-0004",
67
+ token0_address: "0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7",
68
+ token0_decimals: 18,
69
+ token0_standard: "erc20",
70
+ token1_deployment_id: "deployment-0009",
71
+ token1_address: "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e",
72
+ token1_decimals: 6,
73
+ token1_standard: "erc20",
74
+ adapter_kind: SUPPORTED_QUOTE_ADAPTER,
75
+ fee_numerator: "3",
76
+ fee_denominator: "1000"
77
+ }.freeze
78
+ ].freeze
79
+ UINT256_MAX = (1 << 256) - 1
80
+ UINT256_DECIMAL_MAX_LENGTH = 78
81
+ UINT112_MAX = (1 << 112) - 1
82
+ UINT32_MAX = (1 << 32) - 1
83
+
84
+ FACTORY_GET_PAIR_SELECTOR = "0xe6a43905"
85
+ PAIR_FACTORY_SELECTOR = "0xc45a0155"
86
+ PAIR_TOKEN0_SELECTOR = "0x0dfe1681"
87
+ PAIR_TOKEN1_SELECTOR = "0xd21220a7"
88
+ PAIR_GET_RESERVES_SELECTOR = "0x0902f1ac"
89
+
90
+ REQUEST_KEY_ALIASES = {
91
+ "chainId" => :chain_id,
92
+ :chainId => :chain_id,
93
+ "chain_id" => :chain_id,
94
+ :chain_id => :chain_id,
95
+ "poolDefinitionId" => :pool_definition_id,
96
+ :poolDefinitionId => :pool_definition_id,
97
+ "pool_definition_id" => :pool_definition_id,
98
+ :pool_definition_id => :pool_definition_id,
99
+ "inputTokenDeploymentId" => :input_token_deployment_id,
100
+ :inputTokenDeploymentId => :input_token_deployment_id,
101
+ "input_token_deployment_id" => :input_token_deployment_id,
102
+ :input_token_deployment_id => :input_token_deployment_id,
103
+ "outputTokenDeploymentId" => :output_token_deployment_id,
104
+ :outputTokenDeploymentId => :output_token_deployment_id,
105
+ "output_token_deployment_id" => :output_token_deployment_id,
106
+ :output_token_deployment_id => :output_token_deployment_id,
107
+ "amountIn" => :amount_in,
108
+ :amountIn => :amount_in,
109
+ "amount_in" => :amount_in,
110
+ :amount_in => :amount_in,
111
+ "freshness" => :freshness,
112
+ :freshness => :freshness
113
+ }.freeze
114
+
115
+ FRESHNESS_KEY_ALIASES = {
116
+ "maxBlockAgeSeconds" => :max_block_age_seconds,
117
+ :maxBlockAgeSeconds => :max_block_age_seconds,
118
+ "max_block_age_seconds" => :max_block_age_seconds,
119
+ :max_block_age_seconds => :max_block_age_seconds,
120
+ "maxBlockLag" => :max_block_lag,
121
+ :maxBlockLag => :max_block_lag,
122
+ "max_block_lag" => :max_block_lag,
123
+ :max_block_lag => :max_block_lag,
124
+ "maxClockSkewSeconds" => :max_clock_skew_seconds,
125
+ :maxClockSkewSeconds => :max_clock_skew_seconds,
126
+ "max_clock_skew_seconds" => :max_clock_skew_seconds,
127
+ :max_clock_skew_seconds => :max_clock_skew_seconds
128
+ }.freeze
129
+
130
+ REQUIRED_REQUEST_KEYS = %i[
131
+ chain_id
132
+ pool_definition_id
133
+ input_token_deployment_id
134
+ output_token_deployment_id
135
+ amount_in
136
+ ].freeze
137
+
138
+ NormalizedFreshness = Struct.new(
139
+ :max_block_age_seconds,
140
+ :max_block_lag,
141
+ :max_clock_skew_seconds,
142
+ keyword_init: true
143
+ )
144
+ NormalizedRequest = Struct.new(
145
+ :chain_id,
146
+ :pool_definition_id,
147
+ :input_token_deployment_id,
148
+ :output_token_deployment_id,
149
+ :amount_in_text,
150
+ :amount_in,
151
+ :freshness,
152
+ :pool,
153
+ :dex,
154
+ :input,
155
+ :output,
156
+ keyword_init: true
157
+ )
158
+ BlockHeader = Struct.new(:number, :hash, :timestamp, keyword_init: true)
159
+ EvmState = Struct.new(
160
+ :initial,
161
+ :latest_after_reads,
162
+ :reserve0,
163
+ :reserve1,
164
+ keyword_init: true
165
+ )
166
+ CalculatedQuote = Struct.new(
167
+ :amount_out,
168
+ :fee_numerator,
169
+ :fee_denominator,
170
+ keyword_init: true
171
+ )
172
+
173
+ private_constant :REQUEST_KEY_ALIASES
174
+ private_constant :FRESHNESS_KEY_ALIASES
175
+ private_constant :REQUIRED_REQUEST_KEYS
176
+ private_constant :SUPPORTED_QUOTE_CAPABILITIES
177
+ private_constant :NormalizedFreshness
178
+ private_constant :NormalizedRequest
179
+ private_constant :BlockHeader
180
+ private_constant :EvmState
181
+ private_constant :CalculatedQuote
182
+
183
+ def initialize(ethereum_transport:, avalanche_transport:)
184
+ @ethereum_transport = ethereum_transport
185
+ @avalanche_transport = avalanche_transport
186
+ # Tests replace this private implementation slot with a fixed clock for
187
+ # fixture replay. There is no public time or clock bypass.
188
+ @clock = -> { Time.now.to_i }
189
+ end
190
+
191
+ # Execute an exact-input quote through the configured EVM transport.
192
+ # The request is normalized and copied before the first RPC call.
193
+ def quote_exact_input(request)
194
+ normalized = normalize_request(request)
195
+ unless normalized.pool.fetch(:adapter).fetch(:kind) == SUPPORTED_QUOTE_ADAPTER &&
196
+ normalized.dex.fetch(:adapter_kind) == SUPPORTED_QUOTE_ADAPTER
197
+ return domain_error("SWAP_UNSUPPORTED_ADAPTER")
198
+ end
199
+
200
+ transport = case normalized.chain_id
201
+ when DexChainIDs::ETHEREUM_MAINNET then @ethereum_transport
202
+ when DexChainIDs::AVALANCHE_C_MAINNET then @avalanche_transport
203
+ else return domain_error("SWAP_UNSUPPORTED_ADAPTER")
204
+ end
205
+
206
+ state = read_evm_state(transport, normalized)
207
+ assert_freshness(state.initial, state.latest_after_reads, normalized.freshness)
208
+ calculated = calculate_quote(normalized, state)
209
+ # Recheck at completion so a clock change while the buffered state is
210
+ # being decoded cannot turn an old quote into a fresh one.
211
+ assert_freshness(state.initial, state.latest_after_reads, normalized.freshness)
212
+ build_quote_result(normalized, state, calculated)
213
+ end
214
+
215
+ private
216
+
217
+ def normalize_request(request)
218
+ return domain_error("SWAP_INVALID_ARGUMENT") unless request.is_a?(Hash)
219
+
220
+ values = {}
221
+ request.each do |key, value|
222
+ canonical = REQUEST_KEY_ALIASES[key]
223
+ return domain_error("SWAP_INVALID_ARGUMENT") unless canonical
224
+ return domain_error("SWAP_INVALID_ARGUMENT") if values.key?(canonical)
225
+
226
+ values[canonical] = value
227
+ end
228
+ return domain_error("SWAP_INVALID_ARGUMENT") unless REQUIRED_REQUEST_KEYS.all? { |key| values.key?(key) }
229
+ return domain_error("SWAP_INVALID_ARGUMENT") if values.key?(:freshness) && values[:freshness].nil?
230
+
231
+ chain_id = copy_request_string(values[:chain_id])
232
+ pool_definition_id = copy_request_string(values[:pool_definition_id])
233
+ input_token_deployment_id = copy_request_string(values[:input_token_deployment_id])
234
+ output_token_deployment_id = copy_request_string(values[:output_token_deployment_id])
235
+ amount_in_text = copy_request_string(values[:amount_in])
236
+ return domain_error("SWAP_INVALID_ARGUMENT") unless [
237
+ chain_id,
238
+ pool_definition_id,
239
+ input_token_deployment_id,
240
+ output_token_deployment_id,
241
+ amount_in_text
242
+ ].all?
243
+
244
+ [pool_definition_id, input_token_deployment_id, output_token_deployment_id].each do |identifier|
245
+ return domain_error("SWAP_INVALID_ARGUMENT") unless opaque_id?(identifier)
246
+ end
247
+ amount_in = parse_canonical_decimal(amount_in_text, positive: true, code: "SWAP_INVALID_ARGUMENT")
248
+ freshness = normalize_freshness(values[:freshness])
249
+
250
+ return domain_error("SWAP_UNSUPPORTED_CHAIN") unless known_chain_id?(chain_id)
251
+
252
+ pool = DexCatalog.get_pool_definition(pool_definition_id)
253
+ return domain_error("SWAP_UNKNOWN_POOL") unless pool
254
+ return domain_error("SWAP_CHAIN_MISMATCH") unless pool.fetch(:chain_id) == chain_id
255
+ return domain_error("SWAP_INVALID_POOL_STATE") unless pool.fetch(:status) == "active"
256
+
257
+ dex = DexCatalog.get_dex_deployment(pool.fetch(:dex_deployment_id))
258
+ return domain_error("SWAP_INVALID_POOL_STATE") unless dex && dex.fetch(:status) == "active"
259
+
260
+ input = TokenCatalog.get_token_deployment(input_token_deployment_id)
261
+ return domain_error("SWAP_UNKNOWN_TOKEN") unless input
262
+ output = TokenCatalog.get_token_deployment(output_token_deployment_id)
263
+ return domain_error("SWAP_UNKNOWN_TOKEN") unless output
264
+ return domain_error("SWAP_CHAIN_MISMATCH") unless input.fetch(:chain_id) == chain_id && output.fetch(:chain_id) == chain_id
265
+ return domain_error("SWAP_TOKEN_NOT_ACTIVE") unless input.fetch(:status) == "active" && output.fetch(:status) == "active"
266
+
267
+ [input, output].each do |token|
268
+ standard = token.fetch(:standard)
269
+ return domain_error("SWAP_UNSUPPORTED_TOKEN_STANDARD") unless %w[erc20 spl-token].include?(standard)
270
+ end
271
+
272
+ return domain_error("SWAP_POOL_TOKEN_MISMATCH") if input_token_deployment_id == output_token_deployment_id
273
+
274
+ requested_pair = [input_token_deployment_id, output_token_deployment_id].sort
275
+ pool_pair = [pool.fetch(:token0_deployment_id), pool.fetch(:token1_deployment_id)].sort
276
+ return domain_error("SWAP_POOL_TOKEN_MISMATCH") unless requested_pair == pool_pair
277
+
278
+ unless pool.fetch(:adapter).fetch(:kind) == SUPPORTED_QUOTE_ADAPTER &&
279
+ dex.fetch(:adapter_kind) == SUPPORTED_QUOTE_ADAPTER
280
+ return domain_error("SWAP_UNSUPPORTED_ADAPTER")
281
+ end
282
+ return domain_error("SWAP_UNSUPPORTED_TOKEN_STANDARD") unless input.fetch(:standard) == "erc20" && output.fetch(:standard) == "erc20"
283
+ return domain_error("SWAP_UNSUPPORTED_TOKEN") unless supported_quote_capability?(pool, dex, input, output)
284
+
285
+ NormalizedRequest.new(
286
+ chain_id: chain_id,
287
+ pool_definition_id: pool_definition_id,
288
+ input_token_deployment_id: input_token_deployment_id,
289
+ output_token_deployment_id: output_token_deployment_id,
290
+ amount_in_text: amount_in_text,
291
+ amount_in: amount_in,
292
+ freshness: freshness,
293
+ pool: pool,
294
+ dex: dex,
295
+ input: input,
296
+ output: output
297
+ )
298
+ end
299
+
300
+ def copy_request_string(value)
301
+ return nil unless value.is_a?(String)
302
+ return nil if value.empty? || value.strip != value
303
+
304
+ value.dup.freeze
305
+ end
306
+
307
+ def normalize_freshness(value)
308
+ values = {
309
+ max_block_age_seconds: 120,
310
+ max_block_lag: 3,
311
+ max_clock_skew_seconds: 5
312
+ }
313
+ return NormalizedFreshness.new(**values) if value.nil?
314
+ return domain_error("SWAP_INVALID_ARGUMENT") unless value.is_a?(Hash)
315
+
316
+ seen = {}
317
+ value.each do |key, child|
318
+ canonical = FRESHNESS_KEY_ALIASES[key]
319
+ return domain_error("SWAP_INVALID_ARGUMENT") unless canonical
320
+ return domain_error("SWAP_INVALID_ARGUMENT") if seen[canonical]
321
+ return domain_error("SWAP_INVALID_ARGUMENT") unless child.is_a?(Integer)
322
+
323
+ maximum = {
324
+ max_block_age_seconds: 86_400,
325
+ max_block_lag: 1_024,
326
+ max_clock_skew_seconds: 300
327
+ }.fetch(canonical)
328
+ return domain_error("SWAP_INVALID_ARGUMENT") unless child.between?(0, maximum)
329
+
330
+ seen[canonical] = true
331
+ values[canonical] = child
332
+ end
333
+ NormalizedFreshness.new(**values)
334
+ end
335
+
336
+ def known_chain_id?(chain_id)
337
+ DexCatalog::KNOWN_CHAIN_IDS.include?(chain_id)
338
+ end
339
+
340
+ def supported_quote_capability?(pool, dex, input, output)
341
+ capability = SUPPORTED_QUOTE_CAPABILITIES.find do |value|
342
+ value.fetch(:pool_definition_id) == pool.fetch(:pool_definition_id)
343
+ end
344
+ return false unless capability
345
+
346
+ input_asset = TokenCatalog.get_token_asset(input.fetch(:asset_id))
347
+ output_asset = TokenCatalog.get_token_asset(output.fetch(:asset_id))
348
+ return false if [input_asset, output_asset].any? do |asset|
349
+ asset && asset.fetch(:representation_kind) == "unclassified"
350
+ end
351
+
352
+ matches_token = lambda do |token, deployment_key, address_key, decimals_key, standard_key|
353
+ token.fetch(:chain_id) == capability.fetch(:chain_id) &&
354
+ token.fetch(:deployment_id) == capability.fetch(deployment_key) &&
355
+ token.fetch(:address) == capability.fetch(address_key) &&
356
+ token.fetch(:decimals) == capability.fetch(decimals_key) &&
357
+ token.fetch(:standard) == capability.fetch(standard_key)
358
+ end
359
+
360
+ pool.fetch(:chain_id) == capability.fetch(:chain_id) &&
361
+ pool.fetch(:dex_deployment_id) == capability.fetch(:dex_deployment_id) &&
362
+ pool.fetch(:address) == capability.fetch(:pool_address) &&
363
+ pool.fetch(:token0_deployment_id) == capability.fetch(:token0_deployment_id) &&
364
+ pool.fetch(:token1_deployment_id) == capability.fetch(:token1_deployment_id) &&
365
+ dex.fetch(:chain_id) == capability.fetch(:chain_id) &&
366
+ dex.fetch(:dex_deployment_id) == capability.fetch(:dex_deployment_id) &&
367
+ dex.fetch(:program_address) == capability.fetch(:factory_address) &&
368
+ dex.fetch(:adapter_kind) == capability.fetch(:adapter_kind) &&
369
+ pool.fetch(:adapter).fetch(:kind) == capability.fetch(:adapter_kind) &&
370
+ pool.fetch(:adapter).fetch(:fee_numerator) == capability.fetch(:fee_numerator) &&
371
+ pool.fetch(:adapter).fetch(:fee_denominator) == capability.fetch(:fee_denominator) &&
372
+ (matches_token.call(input, :token0_deployment_id, :token0_address, :token0_decimals, :token0_standard) ||
373
+ matches_token.call(input, :token1_deployment_id, :token1_address, :token1_decimals, :token1_standard)) &&
374
+ (matches_token.call(output, :token0_deployment_id, :token0_address, :token0_decimals, :token0_standard) ||
375
+ matches_token.call(output, :token1_deployment_id, :token1_address, :token1_decimals, :token1_standard))
376
+ end
377
+
378
+ def opaque_id?(value)
379
+ value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/)
380
+ end
381
+
382
+ def parse_canonical_decimal(value, positive:, code:)
383
+ return domain_error(code) unless value.is_a?(String) &&
384
+ value.length.between?(1, UINT256_DECIMAL_MAX_LENGTH) &&
385
+ value.match?(/\A[0-9]+\z/) &&
386
+ (value.length == 1 || value[0] != "0")
387
+
388
+ parsed = Integer(value, 10)
389
+ return domain_error(code) if parsed > UINT256_MAX || (positive && parsed.zero?)
390
+
391
+ parsed
392
+ rescue ArgumentError
393
+ domain_error(code)
394
+ end
395
+
396
+ def parse_decimal_quantity(value, code)
397
+ return domain_error(code) unless value.is_a?(String) &&
398
+ value.length.between?(1, UINT256_DECIMAL_MAX_LENGTH) &&
399
+ value.match?(/\A[0-9]+\z/) &&
400
+ (value.length == 1 || value[0] != "0")
401
+
402
+ parsed = Integer(value, 10)
403
+ return domain_error(code) if parsed > UINT256_MAX
404
+
405
+ parsed
406
+ rescue ArgumentError
407
+ domain_error(code)
408
+ end
409
+
410
+ def read_evm_state(transport, normalized)
411
+ network_chain_id = parse_hex_quantity(
412
+ rpc_request(transport, "eth_chainId", []),
413
+ "SWAP_CHAIN_MISMATCH"
414
+ )
415
+ expected_chain_id = normalized.chain_id == DexChainIDs::ETHEREUM_MAINNET ? 1 : 43_114
416
+ return domain_error("SWAP_CHAIN_MISMATCH") unless network_chain_id == expected_chain_id
417
+
418
+ initial = parse_block_header(rpc_request(transport, "eth_getBlockByNumber", ["latest", false]))
419
+ selector = rpc_selector(initial.hash)
420
+ factory_code_raw = rpc_request(
421
+ transport,
422
+ "eth_getCode",
423
+ [normalized.dex.fetch(:program_address), selector]
424
+ )
425
+ pool_code_raw = rpc_request(
426
+ transport,
427
+ "eth_getCode",
428
+ [normalized.pool.fetch(:address), selector]
429
+ )
430
+
431
+ token0 = TokenCatalog.get_token_deployment(normalized.pool.fetch(:token0_deployment_id))
432
+ token1 = TokenCatalog.get_token_deployment(normalized.pool.fetch(:token1_deployment_id))
433
+ return domain_error("SWAP_INVALID_POOL_STATE") unless token0 && token1
434
+ token0_address = token0[:address]
435
+ token1_address = token1[:address]
436
+ return domain_error("SWAP_INVALID_POOL_STATE") unless token0_address && token1_address
437
+
438
+ pair_data = FACTORY_GET_PAIR_SELECTOR + encode_address_argument(token0_address) + encode_address_argument(token1_address)
439
+ factory_pair_raw = rpc_request(
440
+ transport,
441
+ "eth_call",
442
+ [abi_call(normalized.dex.fetch(:program_address), pair_data), selector]
443
+ )
444
+ pair_factory_raw = rpc_request(
445
+ transport,
446
+ "eth_call",
447
+ [abi_call(normalized.pool.fetch(:address), PAIR_FACTORY_SELECTOR), selector]
448
+ )
449
+ pair_token0_raw = rpc_request(
450
+ transport,
451
+ "eth_call",
452
+ [abi_call(normalized.pool.fetch(:address), PAIR_TOKEN0_SELECTOR), selector]
453
+ )
454
+ pair_token1_raw = rpc_request(
455
+ transport,
456
+ "eth_call",
457
+ [abi_call(normalized.pool.fetch(:address), PAIR_TOKEN1_SELECTOR), selector]
458
+ )
459
+ reserves_raw = rpc_request(
460
+ transport,
461
+ "eth_call",
462
+ [abi_call(normalized.pool.fetch(:address), PAIR_GET_RESERVES_SELECTOR), selector]
463
+ )
464
+
465
+ latest_after_reads = parse_block_header(rpc_request(transport, "eth_getBlockByNumber", ["latest", false]))
466
+ snapshot_number = "0x#{initial.number.to_s(16)}"
467
+ reread = parse_block_header(rpc_request(transport, "eth_getBlockByNumber", [snapshot_number, false]))
468
+ if reread.number != initial.number || reread.hash != initial.hash || reread.timestamp != initial.timestamp
469
+ return domain_error("SWAP_STATE_STALE")
470
+ end
471
+
472
+ # Freshness is checked before decoding any buffered ABI response, so a
473
+ # stale snapshot deterministically wins over malformed pool state.
474
+ assert_freshness(initial, latest_after_reads, normalized.freshness)
475
+
476
+ factory_code = parse_hex_bytes(factory_code_raw, expected_bytes: nil, code: "SWAP_INVALID_POOL_STATE")
477
+ return domain_error("SWAP_PROGRAM_MISMATCH") if factory_code.length <= 2
478
+ pool_code = parse_hex_bytes(pool_code_raw, expected_bytes: nil, code: "SWAP_INVALID_POOL_STATE")
479
+ return domain_error("SWAP_INVALID_POOL_STATE") if pool_code.length <= 2
480
+
481
+ factory_pair = parse_address_word(factory_pair_raw)
482
+ return domain_error("SWAP_PROGRAM_MISMATCH") unless factory_pair.casecmp?(normalized.pool.fetch(:address))
483
+ pair_factory = parse_address_word(pair_factory_raw)
484
+ return domain_error("SWAP_PROGRAM_MISMATCH") unless pair_factory.casecmp?(normalized.dex.fetch(:program_address))
485
+ pair_token0 = parse_address_word(pair_token0_raw)
486
+ pair_token1 = parse_address_word(pair_token1_raw)
487
+ return domain_error("SWAP_POOL_TOKEN_MISMATCH") unless pair_token0.casecmp?(token0_address) && pair_token1.casecmp?(token1_address)
488
+
489
+ reserve0, reserve1 = parse_reserves(reserves_raw)
490
+ EvmState.new(
491
+ initial: initial,
492
+ latest_after_reads: latest_after_reads,
493
+ reserve0: reserve0,
494
+ reserve1: reserve1
495
+ )
496
+ end
497
+
498
+ def rpc_request(transport, method, params)
499
+ transport.request(method, params)
500
+ end
501
+
502
+ def parse_hex_quantity(value, code)
503
+ return domain_error(code) unless value.is_a?(String) &&
504
+ value.length.between?(3, 66) &&
505
+ value.start_with?("0x") &&
506
+ value[2..].match?(/\A[0-9a-fA-F]+\z/) &&
507
+ (value.length == 3 || value[2] != "0")
508
+
509
+ parsed = Integer(value[2..], 16)
510
+ return domain_error(code) if parsed > UINT256_MAX
511
+
512
+ parsed
513
+ rescue ArgumentError
514
+ domain_error(code)
515
+ end
516
+
517
+ def parse_hex_bytes(value, expected_bytes:, code:)
518
+ return domain_error(code) unless value.is_a?(String) && value.start_with?("0x")
519
+ hex = value[2..]
520
+ return domain_error(code) unless hex && hex.match?(/\A[0-9a-fA-F]*\z/) && hex.length.even?
521
+ return domain_error(code) if expected_bytes && hex.length != expected_bytes * 2
522
+
523
+ "0x#{hex.downcase}"
524
+ end
525
+
526
+ def parse_block_header(value)
527
+ return domain_error("SWAP_STATE_STALE") unless value.is_a?(Hash)
528
+
529
+ number = parse_hex_quantity(value["number"] || value[:number], "SWAP_STATE_STALE")
530
+ hash = parse_hex_bytes(value["hash"] || value[:hash], expected_bytes: 32, code: "SWAP_STATE_STALE")
531
+ timestamp = parse_hex_quantity(value["timestamp"] || value[:timestamp], "SWAP_STATE_STALE")
532
+ BlockHeader.new(number: number, hash: hash, timestamp: timestamp)
533
+ end
534
+
535
+ def parse_address_word(value)
536
+ parsed = parse_hex_bytes(value, expected_bytes: 32, code: "SWAP_INVALID_POOL_STATE")
537
+ word = parsed[2..]
538
+ return domain_error("SWAP_INVALID_POOL_STATE") unless word[0, 24] == "0" * 24
539
+
540
+ "0x#{word[24, 40]}"
541
+ end
542
+
543
+ def parse_uint_word(word, maximum, code:)
544
+ return domain_error(code) unless word.is_a?(String) && word.length == 64 && word.match?(/\A[0-9a-fA-F]{64}\z/)
545
+
546
+ parsed = Integer(word, 16)
547
+ return domain_error(code) if parsed > maximum
548
+
549
+ parsed
550
+ rescue ArgumentError
551
+ domain_error(code)
552
+ end
553
+
554
+ def parse_reserves(value)
555
+ parsed = parse_hex_bytes(value, expected_bytes: 96, code: "SWAP_INVALID_POOL_STATE")
556
+ payload = parsed[2..]
557
+ reserve0 = parse_uint_word(payload[0, 64], UINT112_MAX, code: "SWAP_INVALID_POOL_STATE")
558
+ reserve1 = parse_uint_word(payload[64, 64], UINT112_MAX, code: "SWAP_INVALID_POOL_STATE")
559
+ parse_uint_word(payload[128, 64], UINT32_MAX, code: "SWAP_INVALID_POOL_STATE")
560
+ [reserve0, reserve1]
561
+ end
562
+
563
+ def rpc_selector(hash)
564
+ { "blockHash" => hash, "requireCanonical" => true }
565
+ end
566
+
567
+ def abi_call(to, data)
568
+ { "to" => to, "data" => data }
569
+ end
570
+
571
+ def encode_address_argument(address)
572
+ return domain_error("SWAP_INVALID_POOL_STATE") unless evm_address?(address)
573
+
574
+ "0" * 24 + address[2..].downcase
575
+ end
576
+
577
+ def evm_address?(address)
578
+ address.is_a?(String) && address.match?(/\A0x[0-9A-Fa-f]{40}\z/)
579
+ end
580
+
581
+ def current_time
582
+ value = @clock.call
583
+ return domain_error("SWAP_INVALID_ARGUMENT") unless value.is_a?(Integer) && value >= 0
584
+
585
+ value
586
+ end
587
+
588
+ def assert_freshness(initial, latest_after_reads, freshness)
589
+ now = current_time
590
+ timestamps = [initial.timestamp, latest_after_reads.timestamp]
591
+ timestamps.each do |timestamp|
592
+ return domain_error("SWAP_STATE_STALE") if timestamp > now + freshness.max_clock_skew_seconds
593
+ return domain_error("SWAP_STATE_STALE") if now > timestamp && now - timestamp > freshness.max_block_age_seconds
594
+ end
595
+
596
+ return domain_error("SWAP_STATE_STALE") if latest_after_reads.number < initial.number
597
+ return domain_error("SWAP_STATE_STALE") if latest_after_reads.number - initial.number > freshness.max_block_lag
598
+ if latest_after_reads.number == initial.number
599
+ return domain_error("SWAP_STATE_STALE") if latest_after_reads.hash != initial.hash
600
+ elsif latest_after_reads.timestamp < initial.timestamp
601
+ return domain_error("SWAP_STATE_STALE")
602
+ end
603
+ if latest_after_reads.number == initial.number && latest_after_reads.hash == initial.hash &&
604
+ latest_after_reads.timestamp != initial.timestamp
605
+ return domain_error("SWAP_STATE_STALE")
606
+ end
607
+ true
608
+ end
609
+
610
+ def calculate_quote(normalized, state)
611
+ fee_numerator = parse_decimal_quantity(
612
+ normalized.pool.fetch(:adapter).fetch(:fee_numerator),
613
+ "SWAP_ARITHMETIC"
614
+ )
615
+ fee_denominator = parse_decimal_quantity(
616
+ normalized.pool.fetch(:adapter).fetch(:fee_denominator),
617
+ "SWAP_ARITHMETIC"
618
+ )
619
+ return domain_error("SWAP_ARITHMETIC") if fee_denominator <= fee_numerator
620
+
621
+ token0 = TokenCatalog.get_token_deployment(normalized.pool.fetch(:token0_deployment_id))
622
+ token0_address = token0 && token0[:address]
623
+ input_address = normalized.input[:address]
624
+ output_address = normalized.output[:address]
625
+ return domain_error("SWAP_INVALID_POOL_STATE") unless token0_address && input_address && output_address
626
+
627
+ input_is_token0 = input_address.casecmp?(token0_address)
628
+ output_is_token0 = output_address.casecmp?(token0_address)
629
+ return domain_error("SWAP_INVALID_POOL_STATE") if input_is_token0 == output_is_token0
630
+
631
+ reserve_in, reserve_out = input_is_token0 ? [state.reserve0, state.reserve1] : [state.reserve1, state.reserve0]
632
+ return domain_error("SWAP_INSUFFICIENT_LIQUIDITY") if reserve_in.zero? || reserve_out.zero?
633
+
634
+ fee_factor = fee_denominator - fee_numerator
635
+ adjusted = checked_uint256(normalized.amount_in * fee_factor)
636
+ reserve_product = checked_uint256(reserve_in * fee_denominator)
637
+ denominator = checked_uint256(reserve_product + adjusted)
638
+ return domain_error("SWAP_ARITHMETIC") if denominator.zero?
639
+ numerator = checked_uint256(adjusted * reserve_out)
640
+ amount_out = checked_uint256(numerator / denominator)
641
+ return domain_error("SWAP_INSUFFICIENT_LIQUIDITY") if amount_out.zero? || amount_out > reserve_out
642
+
643
+ CalculatedQuote.new(
644
+ amount_out: amount_out,
645
+ fee_numerator: fee_numerator,
646
+ fee_denominator: fee_denominator
647
+ )
648
+ end
649
+
650
+ def checked_uint256(value)
651
+ return domain_error("SWAP_ARITHMETIC") if value > UINT256_MAX
652
+
653
+ value
654
+ end
655
+
656
+ def build_quote_result(normalized, state, calculated)
657
+ fee = {
658
+ "numerator" => calculated.fee_numerator.to_s,
659
+ "denominator" => calculated.fee_denominator.to_s
660
+ }.freeze
661
+ snapshot = {
662
+ "kind" => "evm-block",
663
+ "blockNumber" => state.initial.number.to_s,
664
+ "blockHash" => state.initial.hash,
665
+ "blockTimestamp" => state.initial.timestamp.to_s
666
+ }.freeze
667
+ {
668
+ "quoteKind" => "exact-input",
669
+ "chainId" => normalized.chain_id,
670
+ "poolDefinitionId" => normalized.pool_definition_id,
671
+ "dexDeploymentId" => normalized.dex.fetch(:dex_deployment_id),
672
+ "adapterKind" => normalized.pool.fetch(:adapter).fetch(:kind),
673
+ "inputTokenDeploymentId" => normalized.input_token_deployment_id,
674
+ "outputTokenDeploymentId" => normalized.output_token_deployment_id,
675
+ "amountIn" => normalized.amount_in.to_s,
676
+ "amountOut" => calculated.amount_out.to_s,
677
+ "fee" => fee,
678
+ "snapshot" => snapshot,
679
+ "tokenCatalogDigest" => TokenCatalog::TOKEN_CATALOG_CONTENT_DIGEST,
680
+ "dexCatalogDigest" => DexCatalog::DEX_CATALOG_CONTENT_DIGEST
681
+ }.freeze
682
+ end
683
+
684
+ def domain_error(code)
685
+ raise SwapQuoteError, code
686
+ end
687
+ end
688
+ end