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.
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