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.
- uni_exec_engine-0.2.0.dist-info/METADATA +576 -0
- uni_exec_engine-0.2.0.dist-info/RECORD +80 -0
- uni_exec_engine-0.2.0.dist-info/WHEEL +5 -0
- uni_exec_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
- uni_exec_engine-0.2.0.dist-info/top_level.txt +1 -0
- uniswap_autopilot/__init__.py +3 -0
- uniswap_autopilot/analytics/__init__.py +0 -0
- uniswap_autopilot/analytics/il_calculator.py +525 -0
- uniswap_autopilot/analytics/portfolio.py +234 -0
- uniswap_autopilot/analytics/position.py +433 -0
- uniswap_autopilot/analytics/range_suggest.py +270 -0
- uniswap_autopilot/audit.py +194 -0
- uniswap_autopilot/common/__init__.py +0 -0
- uniswap_autopilot/common/approval_cleanup.py +255 -0
- uniswap_autopilot/common/check_balance.py +64 -0
- uniswap_autopilot/common/common.py +804 -0
- uniswap_autopilot/common/deep_link.py +132 -0
- uniswap_autopilot/common/gas.py +141 -0
- uniswap_autopilot/data/auto_trade_policy.example.json +29 -0
- uniswap_autopilot/data/chains.json +135 -0
- uniswap_autopilot/data/common-token-addresses.json +777 -0
- uniswap_autopilot/execute/__init__.py +0 -0
- uniswap_autopilot/execute/_internal/__init__.py +5 -0
- uniswap_autopilot/execute/_internal/constants.py +15 -0
- uniswap_autopilot/execute/_internal/preflight.py +150 -0
- uniswap_autopilot/execute/_internal/pure_signer.py +182 -0
- uniswap_autopilot/execute/_internal/rpc.py +462 -0
- uniswap_autopilot/execute/_internal/signer.py +298 -0
- uniswap_autopilot/execute/_internal/submit.py +73 -0
- uniswap_autopilot/execute/_internal/tx.py +370 -0
- uniswap_autopilot/execute/broadcast.py +380 -0
- uniswap_autopilot/execute/detect.py +52 -0
- uniswap_autopilot/execute/telegram_confirm.py +272 -0
- uniswap_autopilot/lp/compare_pools.py +338 -0
- uniswap_autopilot/lp/v2/__init__.py +0 -0
- uniswap_autopilot/lp/v2/approve.py +100 -0
- uniswap_autopilot/lp/v2/build_tx.py +226 -0
- uniswap_autopilot/lp/v2/flow.py +204 -0
- uniswap_autopilot/lp/v2/pair.py +135 -0
- uniswap_autopilot/lp/v2/positions.py +177 -0
- uniswap_autopilot/lp/v3/__init__.py +0 -0
- uniswap_autopilot/lp/v3/approve.py +109 -0
- uniswap_autopilot/lp/v3/auto_rebalance.py +282 -0
- uniswap_autopilot/lp/v3/build_tx.py +465 -0
- uniswap_autopilot/lp/v3/compound.py +258 -0
- uniswap_autopilot/lp/v3/flow.py +358 -0
- uniswap_autopilot/lp/v3/pool.py +175 -0
- uniswap_autopilot/lp/v3/position.py +112 -0
- uniswap_autopilot/lp/v3/tick.py +75 -0
- uniswap_autopilot/lp/v4/__init__.py +0 -0
- uniswap_autopilot/lp/v4/approve.py +105 -0
- uniswap_autopilot/lp/v4/build_tx.py +669 -0
- uniswap_autopilot/lp/v4/flow.py +368 -0
- uniswap_autopilot/lp/v4/pool.py +174 -0
- uniswap_autopilot/lp/v4/position.py +185 -0
- uniswap_autopilot/policy.py +371 -0
- uniswap_autopilot/price_feed.py +100 -0
- uniswap_autopilot/py.typed +0 -0
- uniswap_autopilot/search/__init__.py +0 -0
- uniswap_autopilot/search/risk.py +200 -0
- uniswap_autopilot/search/search.py +580 -0
- uniswap_autopilot/state_machine.py +315 -0
- uniswap_autopilot/swap/__init__.py +1 -0
- uniswap_autopilot/swap/deep_link.py +69 -0
- uniswap_autopilot/swap/extensions/__init__.py +2 -0
- uniswap_autopilot/swap/extensions/bridge.py +197 -0
- uniswap_autopilot/swap/extensions/limit_order.py +272 -0
- uniswap_autopilot/swap/extensions/slippage.py +123 -0
- uniswap_autopilot/swap/flow.py +693 -0
- uniswap_autopilot/swap/flow_core/__init__.py +2 -0
- uniswap_autopilot/swap/flow_core/artifacts.py +11 -0
- uniswap_autopilot/swap/flow_core/broadcast.py +50 -0
- uniswap_autopilot/swap/flow_core/diagnostics.py +216 -0
- uniswap_autopilot/swap/flow_core/paper.py +113 -0
- uniswap_autopilot/swap/flow_core/policy.py +142 -0
- uniswap_autopilot/swap/links/__init__.py +2 -0
- uniswap_autopilot/swap/links/deep_link.py +69 -0
- uniswap_autopilot/swap/trading_api/permit.py +41 -0
- uniswap_autopilot/swap/trading_api/quote.py +282 -0
- uniswap_autopilot/swap/trading_api/swap.py +248 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from uniswap_autopilot.common.common import (
|
|
12
|
+
dump_json,
|
|
13
|
+
load_local_env,
|
|
14
|
+
normalize_chain,
|
|
15
|
+
resolve_token,
|
|
16
|
+
validate_fee_tier,
|
|
17
|
+
)
|
|
18
|
+
from uniswap_autopilot.lp.v3.pool import query_pool_full_info
|
|
19
|
+
from uniswap_autopilot.lp.v3.tick import price_to_tick, tick_to_price, nearest_usable_tick, fee_tier_to_tick_spacing
|
|
20
|
+
from uniswap_autopilot.search.search import _ds_lookup
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Pair-type detection
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
WRAPPED_NATIVE_SYMBOLS = {"WETH", "WBTC", "WMATIC", "WBNB", "WAVAX", "WGLMR", "WSTAID"}
|
|
28
|
+
|
|
29
|
+
MAJOR_TOKEN_SYMBOLS = {
|
|
30
|
+
"WETH", "WBTC", "WMATIC", "WBNB", "WAVAX",
|
|
31
|
+
"ETH", "BTC", "USDC", "USDT", "DAI",
|
|
32
|
+
"ARB", "OP", "UNI", "LINK", "AAVE",
|
|
33
|
+
"CBETH", "RETH", "WEETH",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_pair_type(token_a_info: dict[str, Any], token_b_info: dict[str, Any]) -> str:
|
|
38
|
+
"""Classify the token pair to determine appropriate range widths."""
|
|
39
|
+
a_stable = bool(token_a_info.get("isStable"))
|
|
40
|
+
b_stable = bool(token_b_info.get("isStable"))
|
|
41
|
+
a_category = token_a_info.get("category")
|
|
42
|
+
b_category = token_b_info.get("category")
|
|
43
|
+
|
|
44
|
+
# Both tokens are stablecoins
|
|
45
|
+
if a_stable and b_stable:
|
|
46
|
+
return "stable_stable"
|
|
47
|
+
if a_category == "stablecoin" and b_category == "stablecoin":
|
|
48
|
+
return "stable_stable"
|
|
49
|
+
|
|
50
|
+
a_symbol = token_a_info.get("symbol", "").upper()
|
|
51
|
+
b_symbol = token_b_info.get("symbol", "").upper()
|
|
52
|
+
|
|
53
|
+
# Both are major tokens (WETH, WBTC, etc.)
|
|
54
|
+
if a_symbol in MAJOR_TOKEN_SYMBOLS and b_symbol in MAJOR_TOKEN_SYMBOLS:
|
|
55
|
+
return "correlated"
|
|
56
|
+
|
|
57
|
+
# One wrapped native + one non-stable
|
|
58
|
+
a_is_wrapped = a_symbol in WRAPPED_NATIVE_SYMBOLS
|
|
59
|
+
b_is_wrapped = b_symbol in WRAPPED_NATIVE_SYMBOLS
|
|
60
|
+
if (a_is_wrapped and not b_stable) or (b_is_wrapped and not a_stable):
|
|
61
|
+
return "major_volatile"
|
|
62
|
+
|
|
63
|
+
return "volatile"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Range width presets (percentage offset from current price)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
_RANGE_WIDTHS: dict[str, dict[str, float]] = {
|
|
71
|
+
"stable_stable": {"CONSERVATIVE": 0.5, "MODERATE": 1.0, "AGGRESSIVE": 2.0},
|
|
72
|
+
"correlated": {"CONSERVATIVE": 5.0, "MODERATE": 10.0, "AGGRESSIVE": 20.0},
|
|
73
|
+
"major_volatile": {"CONSERVATIVE": 10.0, "MODERATE": 20.0, "AGGRESSIVE": 40.0},
|
|
74
|
+
"volatile": {"CONSERVATIVE": 15.0, "MODERATE": 30.0, "AGGRESSIVE": 60.0},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Range suggestion calculator
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def calculate_range_suggestions(
|
|
83
|
+
current_tick: int,
|
|
84
|
+
tick_spacing: int,
|
|
85
|
+
decimals0: int,
|
|
86
|
+
decimals1: int,
|
|
87
|
+
pair_type: str,
|
|
88
|
+
price_change_24h: float | None = None,
|
|
89
|
+
) -> list[dict[str, Any]]:
|
|
90
|
+
"""Generate tick ranges for CONSERVATIVE / MODERATE / AGGRESSIVE profiles."""
|
|
91
|
+
current_price = tick_to_price(current_tick, decimals0, decimals1)
|
|
92
|
+
widths = _RANGE_WIDTHS.get(pair_type, _RANGE_WIDTHS["volatile"])
|
|
93
|
+
|
|
94
|
+
# If 24h price change exceeds a profile's width, widen it by the change magnitude
|
|
95
|
+
change_factor = abs(price_change_24h) if price_change_24h is not None else 0.0
|
|
96
|
+
|
|
97
|
+
suggestions: list[dict[str, Any]] = []
|
|
98
|
+
for profile, base_width in widths.items():
|
|
99
|
+
# Widen the range if recent volatility exceeds the preset width
|
|
100
|
+
effective_width = max(base_width, change_factor * 1.2)
|
|
101
|
+
|
|
102
|
+
price_lower = current_price * (1 - effective_width / 100)
|
|
103
|
+
price_upper = current_price * (1 + effective_width / 100)
|
|
104
|
+
|
|
105
|
+
tick_lower_raw = price_to_tick(price_lower, decimals0, decimals1)
|
|
106
|
+
tick_upper_raw = price_to_tick(price_upper, decimals0, decimals1)
|
|
107
|
+
|
|
108
|
+
tick_lower = nearest_usable_tick(tick_lower_raw, tick_spacing)
|
|
109
|
+
tick_upper = nearest_usable_tick(tick_upper_raw, tick_spacing)
|
|
110
|
+
|
|
111
|
+
# Convert aligned ticks back to human-readable prices
|
|
112
|
+
price_lower_aligned = tick_to_price(tick_lower, decimals0, decimals1)
|
|
113
|
+
price_upper_aligned = tick_to_price(tick_upper, decimals0, decimals1)
|
|
114
|
+
|
|
115
|
+
suggestions.append({
|
|
116
|
+
"profile": profile,
|
|
117
|
+
"tickLower": tick_lower,
|
|
118
|
+
"tickUpper": tick_upper,
|
|
119
|
+
"priceLower": f"{price_lower_aligned:.12g}",
|
|
120
|
+
"priceUpper": f"{price_upper_aligned:.12g}",
|
|
121
|
+
"rangeWidthPct": round(effective_width, 1),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return suggestions
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Main entry point
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def suggest_ranges(
|
|
132
|
+
chain_name: str,
|
|
133
|
+
token_a: str,
|
|
134
|
+
token_b: str,
|
|
135
|
+
fee_tier: int,
|
|
136
|
+
rpc_url: str | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Resolve pool state, detect pair type, and return range suggestions."""
|
|
139
|
+
load_local_env()
|
|
140
|
+
|
|
141
|
+
chain = normalize_chain(chain_name)
|
|
142
|
+
fee = validate_fee_tier(fee_tier)
|
|
143
|
+
|
|
144
|
+
# Resolve tokens (needs RPC for on-chain metadata of raw addresses)
|
|
145
|
+
from uniswap_autopilot.execute._internal.rpc import resolve_rpc_url
|
|
146
|
+
rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
|
|
147
|
+
token_a_info = resolve_token(chain, token_a, rpc)
|
|
148
|
+
token_b_info = resolve_token(chain, token_b, rpc)
|
|
149
|
+
|
|
150
|
+
# Query pool state
|
|
151
|
+
pool_info = query_pool_full_info(chain_name, token_a, token_b, fee, rpc_url)
|
|
152
|
+
if not pool_info.get("exists"):
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
f"No V3 pool found for {token_a}/{token_b} with fee tier {fee_tier} on {chain_name}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
current_tick: int = pool_info["currentTick"]
|
|
158
|
+
current_price = pool_info["currentPrice"]
|
|
159
|
+
tick_spacing = fee_tier_to_tick_spacing(fee)
|
|
160
|
+
|
|
161
|
+
# Determine decimals for token0 / token1 ordering
|
|
162
|
+
token0_addr = pool_info["token0"]
|
|
163
|
+
addr_a = token_a_info.get("address", "")
|
|
164
|
+
if token_a_info.get("address") == "NATIVE":
|
|
165
|
+
wrapped = chain.tokens.get(chain.wrapped_native_symbol.upper())
|
|
166
|
+
addr_a = wrapped.address if wrapped else resolve_token(chain, chain.wrapped_native_symbol, rpc)["address"]
|
|
167
|
+
|
|
168
|
+
if token0_addr.lower() == addr_a.lower():
|
|
169
|
+
decimals0 = token_a_info["decimals"]
|
|
170
|
+
decimals1 = token_b_info["decimals"]
|
|
171
|
+
else:
|
|
172
|
+
decimals0 = token_b_info["decimals"]
|
|
173
|
+
decimals1 = token_a_info["decimals"]
|
|
174
|
+
|
|
175
|
+
# Fetch 24h price change from DexScreener
|
|
176
|
+
price_change_24h: float | None = None
|
|
177
|
+
try:
|
|
178
|
+
ds_token_a = _ds_lookup(chain.key, addr_a)
|
|
179
|
+
if ds_token_a and ds_token_a.get("priceChange"):
|
|
180
|
+
price_change_24h = ds_token_a["priceChange"].get("h24")
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# Detect pair type
|
|
185
|
+
pair_type = detect_pair_type(token_a_info, token_b_info)
|
|
186
|
+
|
|
187
|
+
# Calculate suggestions
|
|
188
|
+
suggestions = calculate_range_suggestions(
|
|
189
|
+
current_tick=current_tick,
|
|
190
|
+
tick_spacing=tick_spacing,
|
|
191
|
+
decimals0=decimals0,
|
|
192
|
+
decimals1=decimals1,
|
|
193
|
+
pair_type=pair_type,
|
|
194
|
+
price_change_24h=price_change_24h,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"action": "range_suggestions",
|
|
199
|
+
"chain": {"key": chain.key, "chainId": chain.chain_id},
|
|
200
|
+
"tokenA": token_a_info,
|
|
201
|
+
"tokenB": token_b_info,
|
|
202
|
+
"feeTier": fee,
|
|
203
|
+
"currentTick": current_tick,
|
|
204
|
+
"currentPrice": f"{current_price:.12g}",
|
|
205
|
+
"pairType": pair_type,
|
|
206
|
+
"priceChange24h": price_change_24h,
|
|
207
|
+
"suggestions": suggestions,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Human-readable table
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _print_suggestion_table(result: dict[str, Any]) -> None:
|
|
216
|
+
"""Print a human-readable table of range suggestions."""
|
|
217
|
+
token_a_sym = result["tokenA"].get("symbol", "?")
|
|
218
|
+
token_b_sym = result["tokenB"].get("symbol", "?")
|
|
219
|
+
pair = f"{token_a_sym}/{token_b_sym}"
|
|
220
|
+
chain = result["chain"]["key"]
|
|
221
|
+
fee = result["feeTier"]
|
|
222
|
+
pair_type = result["pairType"]
|
|
223
|
+
current_price = result["currentPrice"]
|
|
224
|
+
pct_24h = result.get("priceChange24h")
|
|
225
|
+
pct_str = f"{pct_24h:+.2f}%" if pct_24h is not None else "N/A"
|
|
226
|
+
|
|
227
|
+
print(f" Range Suggestions: {pair} on {chain} (fee={fee})")
|
|
228
|
+
print(f" Pair type: {pair_type} | Current price: {current_price} | 24h change: {pct_str}")
|
|
229
|
+
print()
|
|
230
|
+
print(f" {'Profile':<14} {'TickLower':>10} {'TickUpper':>10} {'PriceLower':>18} {'PriceUpper':>18} {'Width':>7}")
|
|
231
|
+
print(f" {'-'*14} {'-'*10} {'-'*10} {'-'*18} {'-'*18} {'-'*7}")
|
|
232
|
+
for s in result["suggestions"]:
|
|
233
|
+
print(
|
|
234
|
+
f" {s['profile']:<14} {s['tickLower']:>10} {s['tickUpper']:>10} "
|
|
235
|
+
f"{s['priceLower']:>18} {s['priceUpper']:>18} {s['rangeWidthPct']:>6.1f}%"
|
|
236
|
+
)
|
|
237
|
+
print()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# CLI
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def main() -> None:
|
|
245
|
+
parser = argparse.ArgumentParser(
|
|
246
|
+
description="Suggest V3 price ranges for a Uniswap pool based on pair type and recent volatility"
|
|
247
|
+
)
|
|
248
|
+
parser.add_argument("--chain", required=True, help="Chain name, e.g. base / ethereum")
|
|
249
|
+
parser.add_argument("--token-a", required=True, help="Token symbol or address")
|
|
250
|
+
parser.add_argument("--token-b", required=True, help="Token symbol or address")
|
|
251
|
+
parser.add_argument("--fee-tier", type=int, required=True, help="Fee tier: 100 / 500 / 3000 / 10000")
|
|
252
|
+
parser.add_argument("--rpc-url", help="RPC URL (reads from env if omitted)")
|
|
253
|
+
parser.add_argument("--output", help="Output JSON file path")
|
|
254
|
+
args = parser.parse_args()
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
result = suggest_ranges(args.chain, args.token_a, args.token_b, args.fee_tier, args.rpc_url)
|
|
258
|
+
_print_suggestion_table(result)
|
|
259
|
+
if args.output:
|
|
260
|
+
Path(args.output).write_text(
|
|
261
|
+
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
262
|
+
)
|
|
263
|
+
dump_json(result)
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
main()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Structured audit logging — single source of truth for cross-project events.
|
|
2
|
+
|
|
3
|
+
Every transaction-touching project emits the same JSON-line schema, so a single
|
|
4
|
+
``cat *.jsonl | jq`` can correlate Uniswap swaps, Hyperliquid orders, and
|
|
5
|
+
Polymarket trades by ``run_id``.
|
|
6
|
+
|
|
7
|
+
Schema (one JSON object per line):
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"ts": <ISO-8601 UTC, e.g. "2026-05-28T07:55:00.123Z">,
|
|
11
|
+
"ts_unix": <float seconds since epoch>,
|
|
12
|
+
"event": <enum: see EVENT_* constants below>,
|
|
13
|
+
"project": <e.g. "evm-wallet-scanner", "uniswap-autopilot">,
|
|
14
|
+
"run_id": <str|null — populated from STAGEFORGE_RUN_ID or caller>,
|
|
15
|
+
"chain": <str|null — e.g. "ethereum", "hyperliquid">,
|
|
16
|
+
"wallet": <str|null — 0x... or trader id>,
|
|
17
|
+
"tx_hash": <str|null — populated once broadcast>,
|
|
18
|
+
"error_code": <str|null — short stable code like "rpc_timeout">,
|
|
19
|
+
"details": <dict — free-form, never None>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Required fields are always present (null when unknown) so downstream consumers
|
|
23
|
+
can rely on the schema without defensive ``in`` checks.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
# ── Event enum (stable strings) ────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
EVENT_PREFLIGHT = "preflight"
|
|
39
|
+
EVENT_QUOTE = "quote"
|
|
40
|
+
EVENT_SIGN = "sign"
|
|
41
|
+
EVENT_BROADCAST = "broadcast"
|
|
42
|
+
EVENT_CONFIRM = "confirm"
|
|
43
|
+
EVENT_CANCEL = "cancel"
|
|
44
|
+
EVENT_ERROR = "error"
|
|
45
|
+
|
|
46
|
+
ALLOWED_EVENTS = frozenset(
|
|
47
|
+
{
|
|
48
|
+
EVENT_PREFLIGHT,
|
|
49
|
+
EVENT_QUOTE,
|
|
50
|
+
EVENT_SIGN,
|
|
51
|
+
EVENT_BROADCAST,
|
|
52
|
+
EVENT_CONFIRM,
|
|
53
|
+
EVENT_CANCEL,
|
|
54
|
+
EVENT_ERROR,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Required-keys order is fixed so jq/grep-based downstream tools have
|
|
59
|
+
# predictable JSON-line layouts.
|
|
60
|
+
REQUIRED_KEYS: tuple[str, ...] = (
|
|
61
|
+
"ts",
|
|
62
|
+
"ts_unix",
|
|
63
|
+
"event",
|
|
64
|
+
"project",
|
|
65
|
+
"run_id",
|
|
66
|
+
"chain",
|
|
67
|
+
"wallet",
|
|
68
|
+
"tx_hash",
|
|
69
|
+
"error_code",
|
|
70
|
+
"details",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_write_lock = threading.Lock()
|
|
75
|
+
_DEFAULT_PROJECT = __name__.split(".")[0].replace("_", "-")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _now() -> tuple[str, float]:
|
|
79
|
+
now = datetime.now(tz=timezone.utc)
|
|
80
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z", now.timestamp()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_run_id(explicit: str | None) -> str | None:
|
|
84
|
+
if explicit:
|
|
85
|
+
return explicit
|
|
86
|
+
for name in ("STAGEFORGE_RUN_ID", "AUDIT_RUN_ID", "RUN_ID"):
|
|
87
|
+
value = (os.environ.get(name) or "").strip()
|
|
88
|
+
if value:
|
|
89
|
+
return value
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_record(
|
|
94
|
+
*,
|
|
95
|
+
event: str,
|
|
96
|
+
project: str = _DEFAULT_PROJECT,
|
|
97
|
+
run_id: str | None = None,
|
|
98
|
+
chain: str | None = None,
|
|
99
|
+
wallet: str | None = None,
|
|
100
|
+
tx_hash: str | None = None,
|
|
101
|
+
error_code: str | None = None,
|
|
102
|
+
details: dict[str, Any] | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Build the canonical record dict (does not emit it)."""
|
|
105
|
+
if event not in ALLOWED_EVENTS:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"unknown audit event {event!r}; allowed: {sorted(ALLOWED_EVENTS)}"
|
|
108
|
+
)
|
|
109
|
+
ts_iso, ts_unix = _now()
|
|
110
|
+
return {
|
|
111
|
+
"ts": ts_iso,
|
|
112
|
+
"ts_unix": ts_unix,
|
|
113
|
+
"event": event,
|
|
114
|
+
"project": project,
|
|
115
|
+
"run_id": _resolve_run_id(run_id),
|
|
116
|
+
"chain": chain,
|
|
117
|
+
"wallet": wallet,
|
|
118
|
+
"tx_hash": tx_hash,
|
|
119
|
+
"error_code": error_code,
|
|
120
|
+
"details": details or {},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def emit(record: dict[str, Any]) -> None:
|
|
125
|
+
"""Write a record to the configured sink(s).
|
|
126
|
+
|
|
127
|
+
The sink target order is:
|
|
128
|
+
|
|
129
|
+
1. The file at ``AUDIT_LOG_PATH`` if set, appended.
|
|
130
|
+
2. stderr — always, so a tail-friendly trail exists.
|
|
131
|
+
"""
|
|
132
|
+
line = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
|
133
|
+
|
|
134
|
+
with _write_lock:
|
|
135
|
+
path = (os.environ.get("AUDIT_LOG_PATH") or "").strip()
|
|
136
|
+
if path:
|
|
137
|
+
try:
|
|
138
|
+
p = Path(path)
|
|
139
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with p.open("a", encoding="utf-8") as fh:
|
|
141
|
+
fh.write(line + "\n")
|
|
142
|
+
except OSError:
|
|
143
|
+
# Never let logging failure break the trade path.
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# stderr fallback — present even when AUDIT_LOG_PATH is set so the
|
|
147
|
+
# operator can ``tail -f`` without finding the file first.
|
|
148
|
+
try:
|
|
149
|
+
sys.stderr.write(line + "\n")
|
|
150
|
+
sys.stderr.flush()
|
|
151
|
+
except Exception: # noqa: BLE001
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def log_event(
|
|
156
|
+
*,
|
|
157
|
+
event: str,
|
|
158
|
+
project: str = _DEFAULT_PROJECT,
|
|
159
|
+
run_id: str | None = None,
|
|
160
|
+
chain: str | None = None,
|
|
161
|
+
wallet: str | None = None,
|
|
162
|
+
tx_hash: str | None = None,
|
|
163
|
+
error_code: str | None = None,
|
|
164
|
+
details: dict[str, Any] | None = None,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Shorthand: build + emit. Returns the emitted record."""
|
|
167
|
+
record = build_record(
|
|
168
|
+
event=event,
|
|
169
|
+
project=project,
|
|
170
|
+
run_id=run_id,
|
|
171
|
+
chain=chain,
|
|
172
|
+
wallet=wallet,
|
|
173
|
+
tx_hash=tx_hash,
|
|
174
|
+
error_code=error_code,
|
|
175
|
+
details=details,
|
|
176
|
+
)
|
|
177
|
+
emit(record)
|
|
178
|
+
return record
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"ALLOWED_EVENTS",
|
|
183
|
+
"EVENT_BROADCAST",
|
|
184
|
+
"EVENT_CANCEL",
|
|
185
|
+
"EVENT_CONFIRM",
|
|
186
|
+
"EVENT_ERROR",
|
|
187
|
+
"EVENT_PREFLIGHT",
|
|
188
|
+
"EVENT_QUOTE",
|
|
189
|
+
"EVENT_SIGN",
|
|
190
|
+
"REQUIRED_KEYS",
|
|
191
|
+
"build_record",
|
|
192
|
+
"emit",
|
|
193
|
+
"log_event",
|
|
194
|
+
]
|
|
File without changes
|