erpc-sdk 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +125 -5
- data/lib/erpc_sdk/bridge.rb +1507 -0
- data/lib/erpc_sdk/client.rb +210 -75
- data/lib/erpc_sdk/config.rb +107 -8
- data/lib/erpc_sdk/errors.rb +100 -9
- data/lib/erpc_sdk/generated/bridge_capabilities.rb +9 -0
- data/lib/erpc_sdk/generated/swap_execution_capabilities.rb +11 -0
- data/lib/erpc_sdk/swap.rb +679 -0
- data/lib/erpc_sdk/transport.rb +69 -20
- data/lib/erpc_sdk/version.rb +1 -1
- data/lib/erpc_sdk/websocket.rb +45 -13
- data/lib/erpc_sdk.rb +1 -0
- metadata +5 -2
data/lib/erpc_sdk/swap.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
require_relative "dex_catalog"
|
|
6
|
+
require_relative "generated/swap_execution_capabilities"
|
|
4
7
|
|
|
5
8
|
module ERPC
|
|
6
9
|
# Stable local failures raised by the RPC-only exact-input quote path.
|
|
@@ -33,6 +36,55 @@ module ERPC
|
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
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
|
+
|
|
36
88
|
# Configured RPC-backed exact-input quote client.
|
|
37
89
|
class SwapClient
|
|
38
90
|
SUPPORTED_QUOTE_ADAPTER = "evm-constant-product-v2"
|
|
@@ -86,6 +138,9 @@ module ERPC
|
|
|
86
138
|
PAIR_TOKEN0_SELECTOR = "0x0dfe1681"
|
|
87
139
|
PAIR_TOKEN1_SELECTOR = "0xd21220a7"
|
|
88
140
|
PAIR_GET_RESERVES_SELECTOR = "0x0902f1ac"
|
|
141
|
+
ROUTER_FACTORY_SELECTOR = "0xc45a0155"
|
|
142
|
+
ROUTER_GET_AMOUNTS_OUT_SELECTOR = "0xd06ca61f"
|
|
143
|
+
ERC20_ALLOWANCE_SELECTOR = "0xdd62ed3e"
|
|
89
144
|
|
|
90
145
|
REQUEST_KEY_ALIASES = {
|
|
91
146
|
"chainId" => :chain_id,
|
|
@@ -127,6 +182,26 @@ module ERPC
|
|
|
127
182
|
:max_clock_skew_seconds => :max_clock_skew_seconds
|
|
128
183
|
}.freeze
|
|
129
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
|
+
|
|
130
205
|
REQUIRED_REQUEST_KEYS = %i[
|
|
131
206
|
chain_id
|
|
132
207
|
pool_definition_id
|
|
@@ -169,16 +244,55 @@ module ERPC
|
|
|
169
244
|
:fee_denominator,
|
|
170
245
|
keyword_init: true
|
|
171
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
|
|
172
280
|
|
|
173
281
|
private_constant :REQUEST_KEY_ALIASES
|
|
174
282
|
private_constant :FRESHNESS_KEY_ALIASES
|
|
283
|
+
private_constant :EXECUTION_REQUEST_KEY_ALIASES
|
|
284
|
+
private_constant :EXECUTION_UNSUPPORTED_QUOTE_CODES
|
|
175
285
|
private_constant :REQUIRED_REQUEST_KEYS
|
|
176
286
|
private_constant :SUPPORTED_QUOTE_CAPABILITIES
|
|
287
|
+
private_constant :EXECUTION_CAPABILITIES
|
|
177
288
|
private_constant :NormalizedFreshness
|
|
178
289
|
private_constant :NormalizedRequest
|
|
179
290
|
private_constant :BlockHeader
|
|
180
291
|
private_constant :EvmState
|
|
181
292
|
private_constant :CalculatedQuote
|
|
293
|
+
private_constant :ExecutionRequestSnapshot
|
|
294
|
+
private_constant :NormalizedExecutionRequest
|
|
295
|
+
private_constant :PreparedExecutionContext
|
|
182
296
|
|
|
183
297
|
def initialize(ethereum_transport:, avalanche_transport:)
|
|
184
298
|
@ethereum_transport = ethereum_transport
|
|
@@ -212,6 +326,26 @@ module ERPC
|
|
|
212
326
|
build_quote_result(normalized, state, calculated)
|
|
213
327
|
end
|
|
214
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
|
+
|
|
215
349
|
private
|
|
216
350
|
|
|
217
351
|
def normalize_request(request)
|
|
@@ -297,6 +431,547 @@ module ERPC
|
|
|
297
431
|
)
|
|
298
432
|
end
|
|
299
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
|
+
|
|
300
975
|
def copy_request_string(value)
|
|
301
976
|
return nil unless value.is_a?(String)
|
|
302
977
|
return nil if value.empty? || value.strip != value
|
|
@@ -684,5 +1359,9 @@ module ERPC
|
|
|
684
1359
|
def domain_error(code)
|
|
685
1360
|
raise SwapQuoteError, code
|
|
686
1361
|
end
|
|
1362
|
+
|
|
1363
|
+
def execution_domain_error(code)
|
|
1364
|
+
raise SwapExecutionError, code
|
|
1365
|
+
end
|
|
687
1366
|
end
|
|
688
1367
|
end
|