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