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