uni-exec-engine 0.2.0__py3-none-any.whl

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.
Files changed (80) hide show
  1. uni_exec_engine-0.2.0.dist-info/METADATA +576 -0
  2. uni_exec_engine-0.2.0.dist-info/RECORD +80 -0
  3. uni_exec_engine-0.2.0.dist-info/WHEEL +5 -0
  4. uni_exec_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
  5. uni_exec_engine-0.2.0.dist-info/top_level.txt +1 -0
  6. uniswap_autopilot/__init__.py +3 -0
  7. uniswap_autopilot/analytics/__init__.py +0 -0
  8. uniswap_autopilot/analytics/il_calculator.py +525 -0
  9. uniswap_autopilot/analytics/portfolio.py +234 -0
  10. uniswap_autopilot/analytics/position.py +433 -0
  11. uniswap_autopilot/analytics/range_suggest.py +270 -0
  12. uniswap_autopilot/audit.py +194 -0
  13. uniswap_autopilot/common/__init__.py +0 -0
  14. uniswap_autopilot/common/approval_cleanup.py +255 -0
  15. uniswap_autopilot/common/check_balance.py +64 -0
  16. uniswap_autopilot/common/common.py +804 -0
  17. uniswap_autopilot/common/deep_link.py +132 -0
  18. uniswap_autopilot/common/gas.py +141 -0
  19. uniswap_autopilot/data/auto_trade_policy.example.json +29 -0
  20. uniswap_autopilot/data/chains.json +135 -0
  21. uniswap_autopilot/data/common-token-addresses.json +777 -0
  22. uniswap_autopilot/execute/__init__.py +0 -0
  23. uniswap_autopilot/execute/_internal/__init__.py +5 -0
  24. uniswap_autopilot/execute/_internal/constants.py +15 -0
  25. uniswap_autopilot/execute/_internal/preflight.py +150 -0
  26. uniswap_autopilot/execute/_internal/pure_signer.py +182 -0
  27. uniswap_autopilot/execute/_internal/rpc.py +462 -0
  28. uniswap_autopilot/execute/_internal/signer.py +298 -0
  29. uniswap_autopilot/execute/_internal/submit.py +73 -0
  30. uniswap_autopilot/execute/_internal/tx.py +370 -0
  31. uniswap_autopilot/execute/broadcast.py +380 -0
  32. uniswap_autopilot/execute/detect.py +52 -0
  33. uniswap_autopilot/execute/telegram_confirm.py +272 -0
  34. uniswap_autopilot/lp/compare_pools.py +338 -0
  35. uniswap_autopilot/lp/v2/__init__.py +0 -0
  36. uniswap_autopilot/lp/v2/approve.py +100 -0
  37. uniswap_autopilot/lp/v2/build_tx.py +226 -0
  38. uniswap_autopilot/lp/v2/flow.py +204 -0
  39. uniswap_autopilot/lp/v2/pair.py +135 -0
  40. uniswap_autopilot/lp/v2/positions.py +177 -0
  41. uniswap_autopilot/lp/v3/__init__.py +0 -0
  42. uniswap_autopilot/lp/v3/approve.py +109 -0
  43. uniswap_autopilot/lp/v3/auto_rebalance.py +282 -0
  44. uniswap_autopilot/lp/v3/build_tx.py +465 -0
  45. uniswap_autopilot/lp/v3/compound.py +258 -0
  46. uniswap_autopilot/lp/v3/flow.py +358 -0
  47. uniswap_autopilot/lp/v3/pool.py +175 -0
  48. uniswap_autopilot/lp/v3/position.py +112 -0
  49. uniswap_autopilot/lp/v3/tick.py +75 -0
  50. uniswap_autopilot/lp/v4/__init__.py +0 -0
  51. uniswap_autopilot/lp/v4/approve.py +105 -0
  52. uniswap_autopilot/lp/v4/build_tx.py +669 -0
  53. uniswap_autopilot/lp/v4/flow.py +368 -0
  54. uniswap_autopilot/lp/v4/pool.py +174 -0
  55. uniswap_autopilot/lp/v4/position.py +185 -0
  56. uniswap_autopilot/policy.py +371 -0
  57. uniswap_autopilot/price_feed.py +100 -0
  58. uniswap_autopilot/py.typed +0 -0
  59. uniswap_autopilot/search/__init__.py +0 -0
  60. uniswap_autopilot/search/risk.py +200 -0
  61. uniswap_autopilot/search/search.py +580 -0
  62. uniswap_autopilot/state_machine.py +315 -0
  63. uniswap_autopilot/swap/__init__.py +1 -0
  64. uniswap_autopilot/swap/deep_link.py +69 -0
  65. uniswap_autopilot/swap/extensions/__init__.py +2 -0
  66. uniswap_autopilot/swap/extensions/bridge.py +197 -0
  67. uniswap_autopilot/swap/extensions/limit_order.py +272 -0
  68. uniswap_autopilot/swap/extensions/slippage.py +123 -0
  69. uniswap_autopilot/swap/flow.py +693 -0
  70. uniswap_autopilot/swap/flow_core/__init__.py +2 -0
  71. uniswap_autopilot/swap/flow_core/artifacts.py +11 -0
  72. uniswap_autopilot/swap/flow_core/broadcast.py +50 -0
  73. uniswap_autopilot/swap/flow_core/diagnostics.py +216 -0
  74. uniswap_autopilot/swap/flow_core/paper.py +113 -0
  75. uniswap_autopilot/swap/flow_core/policy.py +142 -0
  76. uniswap_autopilot/swap/links/__init__.py +2 -0
  77. uniswap_autopilot/swap/links/deep_link.py +69 -0
  78. uniswap_autopilot/swap/trading_api/permit.py +41 -0
  79. uniswap_autopilot/swap/trading_api/quote.py +282 -0
  80. uniswap_autopilot/swap/trading_api/swap.py +248 -0
@@ -0,0 +1,315 @@
1
+ """Trade execution state machine — idempotent, resumable, auditable.
2
+
3
+ Provides a lightweight checkpoint mechanism for transaction workflows that
4
+ share the same ``run_id`` used by the audit system. Each state transition is
5
+ recorded to a JSON state file so that a crashed orchestrator can resume from
6
+ the last safe checkpoint without re-signing or re-broadcasting transactions.
7
+
8
+ States are the same event names used by ``audit.py`` so the audit log and the
9
+ state machine speak the same language:
10
+
11
+ INIT → PREFLIGHT → SIGNED → BROADCAST → CONFIRMED
12
+
13
+ Any state can transition to FAILED (terminal). CANCELLED is also terminal.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import tempfile
21
+ import threading
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ # ── Constants ───────────────────────────────────────────────────────────────
27
+
28
+ STATE_INIT = "init"
29
+ STATE_PREFLIGHT = "preflight"
30
+ STATE_SIGNED = "signed"
31
+ STATE_BROADCAST = "broadcast"
32
+ STATE_CONFIRMED = "confirmed"
33
+ STATE_CANCELLED = "cancelled"
34
+ STATE_FAILED = "failed"
35
+
36
+ TERMINAL_STATES: frozenset[str] = frozenset({STATE_CONFIRMED, STATE_CANCELLED, STATE_FAILED})
37
+
38
+ # Ordered so the comparison STATE_ORDER.index(a) < STATE_ORDER.index(b) works.
39
+ STATE_ORDER: tuple[str, ...] = (
40
+ STATE_INIT,
41
+ STATE_PREFLIGHT,
42
+ STATE_SIGNED,
43
+ STATE_BROADCAST,
44
+ STATE_CONFIRMED,
45
+ )
46
+
47
+ # Valid transition map — any state can additionally go to FAILED.
48
+ _VALID_TRANSITIONS: dict[str, frozenset[str]] = {
49
+ STATE_INIT: frozenset({STATE_PREFLIGHT, STATE_CANCELLED}),
50
+ STATE_PREFLIGHT: frozenset({STATE_SIGNED, STATE_CANCELLED}),
51
+ STATE_SIGNED: frozenset({STATE_BROADCAST, STATE_CANCELLED}),
52
+ STATE_BROADCAST: frozenset({STATE_CONFIRMED}),
53
+ }
54
+
55
+
56
+ # ── Module-level state ──────────────────────────────────────────────────────
57
+
58
+ _write_lock = threading.Lock()
59
+ _DEFAULT_PROJECT = __name__.split(".")[0].replace("_", "-")
60
+
61
+
62
+ def _state_dir() -> Path:
63
+ """Return the directory used for state machine checkpoint files.
64
+
65
+ The directory can be overridden with the ``STAGEFORGE_STATE_DIR``
66
+ environment variable; otherwise ``~/.stageforge/states`` is used.
67
+ """
68
+ env = os.environ.get("STAGEFORGE_STATE_DIR", "").strip()
69
+ if env:
70
+ return Path(env)
71
+ return Path.home() / ".stageforge" / "states"
72
+
73
+
74
+ def _state_path(run_id: str) -> Path:
75
+ """File-system path for a given *run_id*."""
76
+ return _state_dir() / f"{run_id}.json"
77
+
78
+
79
+ def _now_iso() -> str:
80
+ """ISO-8601 UTC timestamp (without microsecond noise)."""
81
+ return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
82
+
83
+
84
+ def _resolve_run_id(explicit: str | None) -> str | None:
85
+ if explicit:
86
+ return explicit
87
+ for name in ("STAGEFORGE_RUN_ID", "AUDIT_RUN_ID", "RUN_ID"):
88
+ value = (os.environ.get(name) or "").strip()
89
+ if value:
90
+ return value
91
+ return None
92
+
93
+
94
+ # ── Action mapping (happy-path states → next action) ─────────────────────
95
+
96
+ _STATE_TO_ACTION: dict[str, str | None] = {
97
+ STATE_INIT: STATE_PREFLIGHT,
98
+ STATE_PREFLIGHT: STATE_SIGNED,
99
+ STATE_SIGNED: STATE_BROADCAST,
100
+ STATE_BROADCAST: STATE_CONFIRMED,
101
+ STATE_CONFIRMED: None,
102
+ STATE_CANCELLED: None,
103
+ STATE_FAILED: None,
104
+ }
105
+
106
+
107
+ # ── Public API ───────────────────────────────────────────────────────────────
108
+
109
+
110
+ def load_state(run_id: str) -> dict[str, Any] | None:
111
+ """Read an existing state checkpoint, or ``None`` if not found."""
112
+ path = _state_path(run_id)
113
+ try:
114
+ with path.open() as fh:
115
+ return json.load(fh) # type: ignore[no-any-return]
116
+ except (FileNotFoundError, json.JSONDecodeError):
117
+ return None
118
+
119
+
120
+ def init_state(
121
+ run_id: str,
122
+ *,
123
+ project: str | None = None,
124
+ payload: dict[str, Any] | None = None,
125
+ ) -> dict[str, Any]:
126
+ """Create (or return) the initial state record.
127
+
128
+ If a state file already exists for *run_id* it is returned unchanged
129
+ (idempotent). Callers that want to start over must delete the file
130
+ first.
131
+ """
132
+ existing = load_state(run_id)
133
+ if existing is not None:
134
+ return existing
135
+
136
+ now = _now_iso()
137
+ state: dict[str, Any] = {
138
+ "run_id": run_id,
139
+ "project": project or _DEFAULT_PROJECT,
140
+ "current_state": STATE_INIT,
141
+ "created_at": now,
142
+ "updated_at": now,
143
+ "transition_log": [],
144
+ "payload": payload or {},
145
+ }
146
+ _atomic_write(_state_path(run_id), state)
147
+ return state
148
+
149
+
150
+ def transition(
151
+ run_id: str,
152
+ target_state: str,
153
+ *,
154
+ payload: dict[str, Any] | None = None,
155
+ ) -> dict[str, Any]:
156
+ """Advance the state machine to *target_state*.
157
+
158
+ Rules:
159
+
160
+ * If *target_state* is FAILED the transition is always accepted
161
+ (any state can fail).
162
+ * If the current state is already >= *target_state* in the happy-path
163
+ order, the call is a no-op (idempotent).
164
+ * If the current state is a terminal state, raises ``RuntimeError``.
165
+ * If the transition is not allowed, raises ``ValueError``.
166
+
167
+ Returns the *updated* state dict.
168
+ """
169
+ state = init_state(run_id) # ensure state file exists
170
+ current = state["current_state"]
171
+
172
+ # Terminal state — no-op if already in target, error otherwise.
173
+ if current in TERMINAL_STATES:
174
+ if current == target_state:
175
+ return state
176
+ raise RuntimeError(
177
+ f"State machine for {run_id} is in terminal state {current!r} — cannot transition to {target_state!r}."
178
+ )
179
+
180
+ # Idempotent — already at or beyond this state.
181
+ if (
182
+ target_state != STATE_FAILED
183
+ and current in STATE_ORDER
184
+ and target_state in STATE_ORDER
185
+ and STATE_ORDER.index(current) >= STATE_ORDER.index(target_state)
186
+ ):
187
+ return state
188
+
189
+ # Validate transition.
190
+ if target_state == STATE_FAILED:
191
+ pass # always allowed
192
+ else:
193
+ allowed = _VALID_TRANSITIONS.get(current, frozenset())
194
+ if target_state not in allowed:
195
+ raise ValueError(
196
+ f"Illegal transition for {run_id}: {current!r} → {target_state!r}. Allowed: {sorted(allowed)}"
197
+ )
198
+
199
+ # Perform transition.
200
+ now = _now_iso()
201
+ transition_entry: dict[str, Any] = {
202
+ "from": current,
203
+ "to": target_state,
204
+ "at": now,
205
+ }
206
+ state["current_state"] = target_state
207
+ state["updated_at"] = now
208
+ state["transition_log"].append(transition_entry)
209
+ if payload is not None:
210
+ state["payload"].update(payload)
211
+
212
+ _atomic_write(_state_path(run_id), state)
213
+ return state
214
+
215
+
216
+ def status(run_id: str) -> dict[str, Any]:
217
+ """Return a high-level status summary for *run_id*."""
218
+ state = load_state(run_id)
219
+ if state is None:
220
+ return {"run_id": run_id, "found": False, "current_state": None}
221
+ return {
222
+ "run_id": run_id,
223
+ "found": True,
224
+ "project": state.get("project"),
225
+ "current_state": state.get("current_state"),
226
+ "created_at": state.get("created_at"),
227
+ "updated_at": state.get("updated_at"),
228
+ "transition_count": len(state.get("transition_log", [])),
229
+ }
230
+
231
+
232
+ def next_action(run_id: str) -> str | None:
233
+ """Return the next allowed happy-path action for *run_id*, or None.
234
+
235
+ Callers should check this before executing any side effect (sign, broadcast,
236
+ confirm). If the returned action does not match what the caller plans to do
237
+ the caller must skip that step — it has already been completed in a prior
238
+ run (anti-replay).
239
+ """
240
+ state = load_state(run_id)
241
+ if state is None:
242
+ return STATE_PREFLIGHT # fresh run — start at preflight
243
+ return _STATE_TO_ACTION.get(state.get("current_state", STATE_INIT))
244
+
245
+
246
+ def resume(run_id: str) -> str | None:
247
+ """Return the action that an interrupted run should resume from, or None.
248
+
249
+ ``resume()`` is a convenience wrapper around ``next_action()`` with clearer
250
+ semantics for orchestrators.
251
+
252
+ Returns ``None`` when the run is already complete (CONFIRMED / CANCELLED /
253
+ FAILED) or the state file is missing.
254
+ """
255
+ return next_action(run_id)
256
+
257
+
258
+ def list_runs() -> list[dict[str, Any]]:
259
+ """Return status summaries for all persisted runs."""
260
+ sd = _state_dir()
261
+ results: list[dict[str, Any]] = []
262
+ if not sd.is_dir():
263
+ return results
264
+ for p in sorted(sd.glob("*.json")):
265
+ run_id = p.stem
266
+ s = load_state(run_id)
267
+ if s is not None:
268
+ results.append(
269
+ {
270
+ "run_id": run_id,
271
+ "project": s.get("project"),
272
+ "current_state": s.get("current_state"),
273
+ "updated_at": s.get("updated_at"),
274
+ }
275
+ )
276
+ return results
277
+
278
+
279
+ # ── Internal helpers ─────────────────────────────────────────────────────────
280
+
281
+
282
+ def _atomic_write(path: Path, data: dict[str, Any]) -> None:
283
+ """Write *data* to *path* atomically (write-tmp + rename)."""
284
+ path.parent.mkdir(parents=True, exist_ok=True)
285
+ with _write_lock:
286
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
287
+ try:
288
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
289
+ json.dump(data, fh, ensure_ascii=False, indent=2)
290
+ os.replace(tmp, path)
291
+ except Exception:
292
+ try:
293
+ os.unlink(tmp)
294
+ except OSError:
295
+ pass
296
+ raise
297
+
298
+
299
+ __all__ = [
300
+ "STATE_BROADCAST",
301
+ "STATE_CANCELLED",
302
+ "STATE_CONFIRMED",
303
+ "STATE_FAILED",
304
+ "STATE_INIT",
305
+ "STATE_PREFLIGHT",
306
+ "STATE_SIGNED",
307
+ "TERMINAL_STATES",
308
+ "init_state",
309
+ "list_runs",
310
+ "load_state",
311
+ "next_action",
312
+ "resume",
313
+ "status",
314
+ "transition",
315
+ ]
@@ -0,0 +1 @@
1
+ """Swap modules: trading API, flow execution, and extensions."""
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+
7
+ from uniswap_autopilot.common.common import (
8
+ add_common_arguments,
9
+ build_swap_link,
10
+ dump_json,
11
+ normalize_chain,
12
+ override_decimals,
13
+ parse_amount,
14
+ resolve_token,
15
+ )
16
+
17
+
18
+ def build_swap_link_response(
19
+ chain_name: str,
20
+ token_in_name: str,
21
+ token_out_name: str,
22
+ amount_value: str,
23
+ field: str = "INPUT",
24
+ token_in_decimals: int | None = None,
25
+ token_out_decimals: int | None = None,
26
+ ) -> dict[str, object]:
27
+ chain = normalize_chain(chain_name)
28
+ amount = parse_amount(amount_value)
29
+ token_in = override_decimals(resolve_token(chain, token_in_name), token_in_decimals)
30
+ token_out = override_decimals(resolve_token(chain, token_out_name), token_out_decimals)
31
+ human_amount = format(amount, "f")
32
+ link = build_swap_link(chain, token_in, token_out, human_amount, field)
33
+ return {
34
+ "action": "build_swap_link",
35
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
36
+ "tokenIn": token_in,
37
+ "tokenOut": token_out,
38
+ "amount": human_amount,
39
+ "field": field,
40
+ "deepLink": link,
41
+ }
42
+
43
+
44
+ def main() -> None:
45
+ parser = argparse.ArgumentParser(description="生成 Uniswap swap deep link")
46
+ add_common_arguments(parser)
47
+ parser.add_argument(
48
+ "--field",
49
+ choices=["INPUT", "OUTPUT"],
50
+ default="INPUT",
51
+ help="value 对应输入还是输出字段",
52
+ )
53
+ args = parser.parse_args()
54
+
55
+ dump_json(
56
+ build_swap_link_response(
57
+ chain_name=args.chain,
58
+ token_in_name=args.token_in,
59
+ token_out_name=args.token_out,
60
+ amount_value=args.amount,
61
+ field=args.field,
62
+ token_in_decimals=args.token_in_decimals,
63
+ token_out_decimals=args.token_out_decimals,
64
+ )
65
+ )
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
@@ -0,0 +1,2 @@
1
+ """Optional swap extension commands."""
2
+
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import time
6
+ from typing import Any
7
+
8
+
9
+ from uniswap_autopilot.common.common import (
10
+ dump_json,
11
+ load_local_env,
12
+ normalize_chain,
13
+ override_decimals,
14
+ parse_amount,
15
+ post_json,
16
+ require_api_key,
17
+ resolve_api_token,
18
+ resolve_token,
19
+ resolve_wallet_address,
20
+ decimal_to_base_units,
21
+ native_currency_address,
22
+ )
23
+
24
+
25
+ def build_cross_chain_quote_payload(
26
+ wallet: str | None,
27
+ src_chain_id: int,
28
+ dst_chain_id: int,
29
+ api_token_in: dict[str, Any],
30
+ api_token_out: dict[str, Any],
31
+ base_amount: str,
32
+ swap_type: str,
33
+ slippage: float,
34
+ ) -> dict[str, Any]:
35
+ return {
36
+ "swapper": wallet or native_currency_address(),
37
+ "tokenIn": api_token_in["address"],
38
+ "tokenOut": api_token_out["address"],
39
+ "tokenInChainId": src_chain_id,
40
+ "tokenOutChainId": dst_chain_id,
41
+ "amount": base_amount,
42
+ "type": swap_type,
43
+ "slippageTolerance": slippage,
44
+ }
45
+
46
+
47
+ def prepare_cross_chain_quote(
48
+ src_chain_name: str,
49
+ dst_chain_name: str,
50
+ token_in_name: str,
51
+ token_out_name: str,
52
+ amount_value: str,
53
+ wallet: str | None = None,
54
+ token_in_decimals: int | None = None,
55
+ token_out_decimals: int | None = None,
56
+ swap_type: str = "EXACT_INPUT",
57
+ slippage: float = 0.5,
58
+ ) -> dict[str, Any]:
59
+ src_chain = normalize_chain(src_chain_name)
60
+ dst_chain = normalize_chain(dst_chain_name)
61
+ amount = parse_amount(amount_value)
62
+ token_in = override_decimals(resolve_token(src_chain, token_in_name), token_in_decimals)
63
+ token_out = override_decimals(resolve_token(dst_chain, token_out_name), token_out_decimals)
64
+ api_token_in = resolve_api_token(src_chain, token_in)
65
+ api_token_out = resolve_api_token(dst_chain, token_out)
66
+ validated_wallet = resolve_wallet_address(wallet, "wallet")
67
+ base_amount = decimal_to_base_units(amount, api_token_in["decimals"])
68
+
69
+ payload = build_cross_chain_quote_payload(
70
+ wallet=validated_wallet,
71
+ src_chain_id=src_chain.chain_id,
72
+ dst_chain_id=dst_chain.chain_id,
73
+ api_token_in=api_token_in,
74
+ api_token_out=api_token_out,
75
+ base_amount=base_amount,
76
+ swap_type=swap_type,
77
+ slippage=slippage,
78
+ )
79
+
80
+ response: dict[str, Any] = {
81
+ "action": "cross_chain_quote",
82
+ "srcChain": {"key": src_chain.key, "chainId": src_chain.chain_id},
83
+ "dstChain": {"key": dst_chain.key, "chainId": dst_chain.chain_id},
84
+ "tokenIn": token_in,
85
+ "tokenOut": token_out,
86
+ "humanAmount": format(amount, "f"),
87
+ "baseAmount": base_amount,
88
+ "requestPayload": payload,
89
+ }
90
+ return response
91
+
92
+
93
+ def fetch_cross_chain_quote(response: dict[str, Any]) -> dict[str, Any]:
94
+ api_key = require_api_key()
95
+ raw = post_json("quote", response["requestPayload"], api_key)
96
+ response["rawQuote"] = raw
97
+ return response
98
+
99
+
100
+ def verify_bridge_arrival(
101
+ dst_chain_name: str,
102
+ token_address: str,
103
+ wallet: str,
104
+ expected_amount_base: str,
105
+ timeout: int = 600,
106
+ poll_interval: int = 30,
107
+ rpc_url: str | None = None,
108
+ ) -> dict[str, Any]:
109
+ from uniswap_autopilot.common.common import PUBLIC_RPC_URLS
110
+ from uniswap_autopilot.execute._internal.rpc import query_erc20_balance, query_native_balance, resolve_rpc_url
111
+
112
+ dst_chain = normalize_chain(dst_chain_name)
113
+ rpc = rpc_url or PUBLIC_RPC_URLS.get(dst_chain.key) or resolve_rpc_url(None, dst_chain.chain_id)[0]
114
+ if not rpc:
115
+ raise RuntimeError(f"RPC URL not configured for {dst_chain_name}")
116
+
117
+ expected = int(expected_amount_base)
118
+ start = time.time()
119
+ polls = 0
120
+ balance = 0
121
+
122
+ while time.time() - start < timeout:
123
+ polls += 1
124
+ try:
125
+ if token_address in ("NATIVE", native_currency_address()):
126
+ balance = query_native_balance(wallet, rpc)
127
+ else:
128
+ balance = query_erc20_balance(wallet, token_address, rpc)
129
+ if balance >= expected:
130
+ return {
131
+ "arrived": True,
132
+ "balance": str(balance),
133
+ "expected": str(expected),
134
+ "elapsedSeconds": int(time.time() - start),
135
+ "polls": polls,
136
+ }
137
+ except Exception:
138
+ pass
139
+ time.sleep(poll_interval)
140
+
141
+ return {
142
+ "arrived": False,
143
+ "balance": str(balance),
144
+ "expected": str(expected),
145
+ "elapsedSeconds": int(time.time() - start),
146
+ "polls": polls,
147
+ "timeout": True,
148
+ }
149
+
150
+
151
+ def main() -> None:
152
+ parser = argparse.ArgumentParser(description="Cross-chain swap via Uniswap Trading API")
153
+ sub = parser.add_subparsers(dest="command")
154
+
155
+ q = sub.add_parser("quote", help="Get cross-chain quote")
156
+ q.add_argument("--src-chain", required=True, help="Source chain")
157
+ q.add_argument("--dst-chain", required=True, help="Destination chain")
158
+ q.add_argument("--token-in", required=True)
159
+ q.add_argument("--token-out", required=True)
160
+ q.add_argument("--amount", required=True)
161
+ q.add_argument("--wallet")
162
+ q.add_argument("--slippage", type=float, default=0.5)
163
+ q.add_argument("--request-only", action="store_true", help="Only print payload")
164
+
165
+ v = sub.add_parser("verify", help="Verify bridge arrival")
166
+ v.add_argument("--dst-chain", required=True)
167
+ v.add_argument("--token-address", required=True)
168
+ v.add_argument("--wallet", required=True)
169
+ v.add_argument("--expected-amount", required=True, help="Expected amount in base units")
170
+ v.add_argument("--timeout", type=int, default=600)
171
+ v.add_argument("--poll-interval", type=int, default=30)
172
+
173
+ args = parser.parse_args()
174
+ load_local_env()
175
+
176
+ if args.command == "quote":
177
+ result = prepare_cross_chain_quote(
178
+ args.src_chain, args.dst_chain, args.token_in, args.token_out,
179
+ args.amount, args.wallet, slippage=args.slippage,
180
+ )
181
+ if not args.request_only:
182
+ result = fetch_cross_chain_quote(result)
183
+ dump_json(result)
184
+ elif args.command == "verify":
185
+ result = verify_bridge_arrival(
186
+ args.dst_chain, args.token_address, args.wallet,
187
+ args.expected_amount, args.timeout, args.poll_interval,
188
+ )
189
+ status = "ARRIVED" if result["arrived"] else "TIMEOUT"
190
+ print(f"Bridge status: {status} ({result['polls']} polls, {result['elapsedSeconds']}s)")
191
+ dump_json(result)
192
+ else:
193
+ parser.print_help()
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()