brawny 0.1.13__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.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Alerts extension with contract handles, ABI resolution, and event decoding.
|
|
2
|
+
|
|
3
|
+
This extension provides an ergonomic interface for job authors to:
|
|
4
|
+
- Interact with contracts in alert hooks
|
|
5
|
+
- Decode events from transaction receipts (brownie-compatible)
|
|
6
|
+
- Make contract reads
|
|
7
|
+
- Format messages with explorer links
|
|
8
|
+
|
|
9
|
+
Key components:
|
|
10
|
+
- AlertContext: Context passed to alert hooks with event access
|
|
11
|
+
- ContractHandle: Interface for contract function calls
|
|
12
|
+
- EventDict: Brownie-compatible event container
|
|
13
|
+
- ABIResolver: Automatic ABI resolution with caching
|
|
14
|
+
|
|
15
|
+
Formatting helpers (Markdown is the default):
|
|
16
|
+
- shorten(hash): "0x1234...5678"
|
|
17
|
+
- explorer_link(hash): "[🔗 View on Explorer](url)"
|
|
18
|
+
- escape_markdown_v2(text): Escapes special characters
|
|
19
|
+
|
|
20
|
+
Usage in alert hooks:
|
|
21
|
+
|
|
22
|
+
from brawny import Contract
|
|
23
|
+
from brawny.alerts import shorten, explorer_link
|
|
24
|
+
|
|
25
|
+
def alert_confirmed(self, ctx: AlertContext) -> str:
|
|
26
|
+
# Get contract handle (brownie-style)
|
|
27
|
+
token = Contract("token")
|
|
28
|
+
|
|
29
|
+
# Decode events from receipt (brownie-compatible)
|
|
30
|
+
deposit = ctx.events["Deposit"][0]
|
|
31
|
+
amount = deposit["assets"]
|
|
32
|
+
|
|
33
|
+
# Make contract reads
|
|
34
|
+
symbol = token.symbol()
|
|
35
|
+
decimals = token.decimals()
|
|
36
|
+
|
|
37
|
+
# Format with explorer links
|
|
38
|
+
tx_link = explorer_link(ctx.receipt.transactionHash.hex())
|
|
39
|
+
|
|
40
|
+
return f"Deposited {amount / 10**decimals} {symbol}\\n{tx_link}"
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from brawny.alerts.context import AlertContext, JobMetadata
|
|
44
|
+
from brawny.alerts.contracts import (
|
|
45
|
+
ContractSystem,
|
|
46
|
+
ContractHandle,
|
|
47
|
+
FunctionCaller,
|
|
48
|
+
ExplicitFunctionCaller,
|
|
49
|
+
)
|
|
50
|
+
from brawny.alerts.events import (
|
|
51
|
+
EventAccessor,
|
|
52
|
+
DecodedEvent,
|
|
53
|
+
AttributeDict,
|
|
54
|
+
LogEntry,
|
|
55
|
+
)
|
|
56
|
+
from brawny.alerts.abi_resolver import ABIResolver, ResolvedABI
|
|
57
|
+
from brawny.alerts.base import (
|
|
58
|
+
shorten,
|
|
59
|
+
explorer_link,
|
|
60
|
+
escape_markdown_v2,
|
|
61
|
+
get_explorer_url,
|
|
62
|
+
format_tx_link,
|
|
63
|
+
format_address_link,
|
|
64
|
+
)
|
|
65
|
+
from brawny.alerts.send import (
|
|
66
|
+
AlertEvent,
|
|
67
|
+
AlertPayload,
|
|
68
|
+
AlertConfig,
|
|
69
|
+
send_alert,
|
|
70
|
+
alert,
|
|
71
|
+
)
|
|
72
|
+
from brawny.alerts.errors import (
|
|
73
|
+
DXError,
|
|
74
|
+
ABINotFoundError,
|
|
75
|
+
ProxyResolutionError,
|
|
76
|
+
StateChangingCallError,
|
|
77
|
+
ReceiptRequiredError,
|
|
78
|
+
EventNotFoundError,
|
|
79
|
+
AmbiguousOverloadError,
|
|
80
|
+
OverloadMatchError,
|
|
81
|
+
FunctionNotFoundError,
|
|
82
|
+
InvalidAddressError,
|
|
83
|
+
EventDecodeError,
|
|
84
|
+
ContractCallError,
|
|
85
|
+
ABICacheError,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
# Context
|
|
90
|
+
"AlertContext",
|
|
91
|
+
"JobMetadata",
|
|
92
|
+
# Contracts
|
|
93
|
+
"ContractHandle",
|
|
94
|
+
"FunctionCaller",
|
|
95
|
+
"ExplicitFunctionCaller",
|
|
96
|
+
"ContractSystem",
|
|
97
|
+
# Events
|
|
98
|
+
"EventAccessor",
|
|
99
|
+
"DecodedEvent",
|
|
100
|
+
"AttributeDict",
|
|
101
|
+
"LogEntry",
|
|
102
|
+
# ABI Resolution
|
|
103
|
+
"ABIResolver",
|
|
104
|
+
"ResolvedABI",
|
|
105
|
+
# Alert System
|
|
106
|
+
"AlertEvent",
|
|
107
|
+
"AlertPayload",
|
|
108
|
+
"AlertConfig",
|
|
109
|
+
"send_alert",
|
|
110
|
+
"alert",
|
|
111
|
+
# Formatting
|
|
112
|
+
"shorten",
|
|
113
|
+
"explorer_link",
|
|
114
|
+
"escape_markdown_v2",
|
|
115
|
+
"get_explorer_url",
|
|
116
|
+
"format_tx_link",
|
|
117
|
+
"format_address_link",
|
|
118
|
+
# Errors
|
|
119
|
+
"DXError",
|
|
120
|
+
"ABINotFoundError",
|
|
121
|
+
"ProxyResolutionError",
|
|
122
|
+
"StateChangingCallError",
|
|
123
|
+
"ReceiptRequiredError",
|
|
124
|
+
"EventNotFoundError",
|
|
125
|
+
"AmbiguousOverloadError",
|
|
126
|
+
"OverloadMatchError",
|
|
127
|
+
"FunctionNotFoundError",
|
|
128
|
+
"InvalidAddressError",
|
|
129
|
+
"EventDecodeError",
|
|
130
|
+
"ContractCallError",
|
|
131
|
+
"ABICacheError",
|
|
132
|
+
]
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""ABI resolution with Etherscan, Sourcify fallback, and proxy detection.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
1. ABI fetching from Etherscan API
|
|
5
|
+
2. Sourcify fallback when Etherscan fails
|
|
6
|
+
3. EIP-1967 proxy detection and implementation resolution
|
|
7
|
+
4. Persistent caching in global ~/.brawny/abi_cache.db
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from eth_utils import to_checksum_address
|
|
20
|
+
|
|
21
|
+
from brawny.alerts.errors import (
|
|
22
|
+
ABINotFoundError,
|
|
23
|
+
InvalidAddressError,
|
|
24
|
+
ProxyResolutionError,
|
|
25
|
+
)
|
|
26
|
+
from brawny.db.global_cache import GlobalABICache
|
|
27
|
+
from brawny.logging import get_logger
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from brawny.config import Config
|
|
31
|
+
from brawny._rpc.manager import RPCManager
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
logger = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# EIP-1967 storage slots for proxy detection
|
|
37
|
+
IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
|
|
38
|
+
BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
|
|
39
|
+
ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"
|
|
40
|
+
|
|
41
|
+
# Empty slot value (32 zero bytes)
|
|
42
|
+
EMPTY_SLOT = "0x" + "00" * 32
|
|
43
|
+
|
|
44
|
+
# Etherscan v2 API unified endpoint
|
|
45
|
+
ETHERSCAN_V2_API_URL = "https://api.etherscan.io/v2/api"
|
|
46
|
+
|
|
47
|
+
# Supported chain IDs for Etherscan v2 API
|
|
48
|
+
ETHERSCAN_SUPPORTED_CHAINS = {
|
|
49
|
+
1, # Ethereum Mainnet
|
|
50
|
+
5, # Goerli (deprecated)
|
|
51
|
+
11155111, # Sepolia
|
|
52
|
+
137, # Polygon
|
|
53
|
+
42161, # Arbitrum One
|
|
54
|
+
10, # Optimism
|
|
55
|
+
8453, # Base
|
|
56
|
+
56, # BSC
|
|
57
|
+
43114, # Avalanche C-Chain
|
|
58
|
+
250, # Fantom
|
|
59
|
+
42170, # Arbitrum Nova
|
|
60
|
+
59144, # Linea
|
|
61
|
+
534352, # Scroll
|
|
62
|
+
324, # zkSync Era
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Chain ID to Sourcify chain name
|
|
66
|
+
SOURCIFY_CHAIN_IDS = {
|
|
67
|
+
1: "1",
|
|
68
|
+
5: "5",
|
|
69
|
+
11155111: "11155111",
|
|
70
|
+
137: "137",
|
|
71
|
+
42161: "42161",
|
|
72
|
+
10: "10",
|
|
73
|
+
8453: "8453",
|
|
74
|
+
56: "56",
|
|
75
|
+
43114: "43114",
|
|
76
|
+
250: "250",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ResolvedABI:
|
|
82
|
+
"""Result of ABI resolution."""
|
|
83
|
+
|
|
84
|
+
address: str
|
|
85
|
+
abi: list[dict[str, Any]]
|
|
86
|
+
source: str # 'etherscan', 'sourcify', 'manual', 'proxy_implementation'
|
|
87
|
+
implementation_address: str | None = None # Set if resolved through proxy
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ABIResolver:
|
|
91
|
+
"""Resolves contract ABIs with caching and proxy support.
|
|
92
|
+
|
|
93
|
+
Resolution order:
|
|
94
|
+
1. Check database cache (with TTL validation)
|
|
95
|
+
2. Detect if address is a proxy and resolve implementation
|
|
96
|
+
3. Try Etherscan API
|
|
97
|
+
4. Try Sourcify as fallback
|
|
98
|
+
5. Raise ABINotFoundError if all fail
|
|
99
|
+
|
|
100
|
+
Proxy detection:
|
|
101
|
+
- Checks EIP-1967 implementation slot
|
|
102
|
+
- Checks EIP-1967 beacon slot (and reads implementation from beacon)
|
|
103
|
+
- Recursively resolves up to 3 levels deep
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
rpc: RPCManager,
|
|
109
|
+
config: Config,
|
|
110
|
+
abi_cache: GlobalABICache | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.abi_cache = abi_cache or GlobalABICache()
|
|
113
|
+
self.rpc = rpc
|
|
114
|
+
self.config = config
|
|
115
|
+
self._etherscan_api_url = ETHERSCAN_V2_API_URL
|
|
116
|
+
self._etherscan_api_key = os.environ.get("ETHERSCAN_API_KEY")
|
|
117
|
+
self._sourcify_enabled = True
|
|
118
|
+
self._request_timeout = 10.0
|
|
119
|
+
|
|
120
|
+
def resolve(
|
|
121
|
+
self,
|
|
122
|
+
address: str,
|
|
123
|
+
chain_id: int | None = None,
|
|
124
|
+
force_refresh: bool = False,
|
|
125
|
+
) -> ResolvedABI:
|
|
126
|
+
"""Resolve ABI for a contract address.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
address: Contract address to resolve ABI for
|
|
130
|
+
chain_id: Chain ID (defaults to config.chain_id)
|
|
131
|
+
force_refresh: Bypass cache and fetch fresh ABI
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
ResolvedABI with the contract ABI and metadata
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ABINotFoundError: If ABI cannot be resolved from any source
|
|
138
|
+
InvalidAddressError: If address is invalid
|
|
139
|
+
"""
|
|
140
|
+
# Validate and normalize address
|
|
141
|
+
try:
|
|
142
|
+
address = to_checksum_address(address)
|
|
143
|
+
except Exception:
|
|
144
|
+
raise InvalidAddressError(address)
|
|
145
|
+
|
|
146
|
+
chain_id = chain_id or self.config.chain_id
|
|
147
|
+
checked_sources: list[str] = []
|
|
148
|
+
|
|
149
|
+
# Step 1: Check cache (unless force refresh)
|
|
150
|
+
if not force_refresh:
|
|
151
|
+
cached = self.abi_cache.get_cached_abi(chain_id, address)
|
|
152
|
+
if cached and not self._is_cache_expired(cached.resolved_at):
|
|
153
|
+
return ResolvedABI(
|
|
154
|
+
address=address,
|
|
155
|
+
abi=json.loads(cached.abi_json),
|
|
156
|
+
source=cached.source,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Step 2: Check if proxy and resolve implementation
|
|
160
|
+
impl_address = self._resolve_proxy_implementation(chain_id, address)
|
|
161
|
+
if impl_address and impl_address != address:
|
|
162
|
+
logger.debug(
|
|
163
|
+
"abi.proxy_detected",
|
|
164
|
+
proxy=address,
|
|
165
|
+
implementation=impl_address,
|
|
166
|
+
)
|
|
167
|
+
# Resolve ABI for implementation
|
|
168
|
+
try:
|
|
169
|
+
impl_abi = self._fetch_abi(chain_id, impl_address, checked_sources)
|
|
170
|
+
if impl_abi:
|
|
171
|
+
# Cache both the proxy resolution and the ABI
|
|
172
|
+
self.abi_cache.set_cached_proxy(chain_id, address, impl_address)
|
|
173
|
+
self.abi_cache.set_cached_abi(
|
|
174
|
+
chain_id,
|
|
175
|
+
address,
|
|
176
|
+
json.dumps(impl_abi),
|
|
177
|
+
"proxy_implementation",
|
|
178
|
+
)
|
|
179
|
+
return ResolvedABI(
|
|
180
|
+
address=address,
|
|
181
|
+
abi=impl_abi,
|
|
182
|
+
source="proxy_implementation",
|
|
183
|
+
implementation_address=impl_address,
|
|
184
|
+
)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"abi.impl_resolution_failed",
|
|
188
|
+
proxy=address,
|
|
189
|
+
implementation=impl_address,
|
|
190
|
+
error=str(e),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Step 3: Try to fetch ABI directly
|
|
194
|
+
abi = self._fetch_abi(chain_id, address, checked_sources)
|
|
195
|
+
if abi:
|
|
196
|
+
source = checked_sources[-1] if checked_sources else "unknown"
|
|
197
|
+
self.abi_cache.set_cached_abi(chain_id, address, json.dumps(abi), source)
|
|
198
|
+
return ResolvedABI(address=address, abi=abi, source=source)
|
|
199
|
+
|
|
200
|
+
raise ABINotFoundError(address, checked_sources)
|
|
201
|
+
|
|
202
|
+
def _is_cache_expired(self, resolved_at: datetime) -> bool:
|
|
203
|
+
"""Check if cached entry is expired based on TTL."""
|
|
204
|
+
if self.config.abi_cache_ttl_seconds <= 0:
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
now = datetime.now(timezone.utc)
|
|
208
|
+
if resolved_at.tzinfo is None:
|
|
209
|
+
resolved_at = resolved_at.replace(tzinfo=timezone.utc)
|
|
210
|
+
|
|
211
|
+
age_seconds = (now - resolved_at).total_seconds()
|
|
212
|
+
return age_seconds > self.config.abi_cache_ttl_seconds
|
|
213
|
+
|
|
214
|
+
def _resolve_proxy_implementation(
|
|
215
|
+
self,
|
|
216
|
+
chain_id: int,
|
|
217
|
+
address: str,
|
|
218
|
+
depth: int = 0,
|
|
219
|
+
) -> str | None:
|
|
220
|
+
"""Resolve proxy to implementation address using EIP-1967.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
chain_id: Chain ID
|
|
224
|
+
address: Proxy address
|
|
225
|
+
depth: Current recursion depth (max 3)
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Implementation address or None if not a proxy
|
|
229
|
+
"""
|
|
230
|
+
if depth > 3:
|
|
231
|
+
logger.warning("proxy.max_depth_exceeded", address=address)
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
# Check cache first
|
|
235
|
+
cached = self.abi_cache.get_cached_proxy(chain_id, address)
|
|
236
|
+
if cached:
|
|
237
|
+
# Recursively check if cached implementation is also a proxy
|
|
238
|
+
nested = self._resolve_proxy_implementation(
|
|
239
|
+
chain_id, cached.implementation_address, depth + 1
|
|
240
|
+
)
|
|
241
|
+
return nested or cached.implementation_address
|
|
242
|
+
|
|
243
|
+
# Try EIP-1967 implementation slot
|
|
244
|
+
try:
|
|
245
|
+
impl = self._read_storage_slot(address, IMPLEMENTATION_SLOT)
|
|
246
|
+
if impl and impl != EMPTY_SLOT:
|
|
247
|
+
impl_address = self._slot_to_address(impl)
|
|
248
|
+
if impl_address:
|
|
249
|
+
# Recursively check if implementation is also a proxy
|
|
250
|
+
nested = self._resolve_proxy_implementation(
|
|
251
|
+
chain_id, impl_address, depth + 1
|
|
252
|
+
)
|
|
253
|
+
final_impl = nested or impl_address
|
|
254
|
+
self.abi_cache.set_cached_proxy(chain_id, address, final_impl)
|
|
255
|
+
return final_impl
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.debug("proxy.impl_slot_error", address=address, error=str(e))
|
|
258
|
+
|
|
259
|
+
# Try EIP-1967 beacon slot
|
|
260
|
+
try:
|
|
261
|
+
beacon = self._read_storage_slot(address, BEACON_SLOT)
|
|
262
|
+
if beacon and beacon != EMPTY_SLOT:
|
|
263
|
+
beacon_address = self._slot_to_address(beacon)
|
|
264
|
+
if beacon_address:
|
|
265
|
+
# Read implementation from beacon
|
|
266
|
+
impl_address = self._read_beacon_implementation(beacon_address)
|
|
267
|
+
if impl_address:
|
|
268
|
+
nested = self._resolve_proxy_implementation(
|
|
269
|
+
chain_id, impl_address, depth + 1
|
|
270
|
+
)
|
|
271
|
+
final_impl = nested or impl_address
|
|
272
|
+
self.abi_cache.set_cached_proxy(chain_id, address, final_impl)
|
|
273
|
+
return final_impl
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.debug("proxy.beacon_slot_error", address=address, error=str(e))
|
|
276
|
+
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def _read_storage_slot(self, address: str, slot: str) -> str | None:
|
|
280
|
+
"""Read a storage slot from a contract."""
|
|
281
|
+
try:
|
|
282
|
+
result = self.rpc.get_storage_at(address, slot)
|
|
283
|
+
if result is None:
|
|
284
|
+
return None
|
|
285
|
+
# Convert bytes to hex string if needed
|
|
286
|
+
if isinstance(result, bytes):
|
|
287
|
+
return "0x" + result.hex()
|
|
288
|
+
return result
|
|
289
|
+
except Exception:
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def _slot_to_address(self, slot_value: str) -> str | None:
|
|
293
|
+
"""Extract address from storage slot value."""
|
|
294
|
+
if not slot_value or slot_value == EMPTY_SLOT:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
# Remove 0x prefix and take last 40 chars (20 bytes = address)
|
|
298
|
+
if slot_value.startswith("0x"):
|
|
299
|
+
slot_value = slot_value[2:]
|
|
300
|
+
|
|
301
|
+
if len(slot_value) < 40:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
# Address is in the last 40 characters
|
|
305
|
+
addr_hex = slot_value[-40:]
|
|
306
|
+
|
|
307
|
+
# Check if it's a valid non-zero address
|
|
308
|
+
if addr_hex == "0" * 40:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
return to_checksum_address("0x" + addr_hex)
|
|
313
|
+
except Exception:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
def _read_beacon_implementation(self, beacon_address: str) -> str | None:
|
|
317
|
+
"""Read implementation address from a beacon contract.
|
|
318
|
+
|
|
319
|
+
Beacons typically have an implementation() function.
|
|
320
|
+
"""
|
|
321
|
+
# implementation() function selector: 0x5c60da1b
|
|
322
|
+
try:
|
|
323
|
+
tx_params = {"to": beacon_address, "data": "0x5c60da1b"}
|
|
324
|
+
result = self.rpc.eth_call(tx_params)
|
|
325
|
+
if result:
|
|
326
|
+
# Convert bytes to hex string if needed
|
|
327
|
+
if isinstance(result, bytes):
|
|
328
|
+
result = "0x" + result.hex()
|
|
329
|
+
if result != "0x":
|
|
330
|
+
return self._slot_to_address(result)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.debug(
|
|
333
|
+
"proxy.beacon_call_error",
|
|
334
|
+
beacon=beacon_address,
|
|
335
|
+
error=str(e),
|
|
336
|
+
)
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def _fetch_abi(
|
|
340
|
+
self,
|
|
341
|
+
chain_id: int,
|
|
342
|
+
address: str,
|
|
343
|
+
checked_sources: list[str],
|
|
344
|
+
) -> list[dict[str, Any]] | None:
|
|
345
|
+
"""Fetch ABI from external sources.
|
|
346
|
+
|
|
347
|
+
Tries Etherscan v2 API first, then Sourcify as fallback.
|
|
348
|
+
"""
|
|
349
|
+
# Try Etherscan first (works without API key, but rate-limited)
|
|
350
|
+
if chain_id in ETHERSCAN_SUPPORTED_CHAINS:
|
|
351
|
+
checked_sources.append("etherscan")
|
|
352
|
+
abi = self._fetch_from_etherscan(chain_id, address)
|
|
353
|
+
if abi:
|
|
354
|
+
return abi
|
|
355
|
+
|
|
356
|
+
# Try Sourcify as fallback if enabled
|
|
357
|
+
if self._sourcify_enabled:
|
|
358
|
+
checked_sources.append("sourcify")
|
|
359
|
+
abi = self._fetch_from_sourcify(chain_id, address)
|
|
360
|
+
if abi:
|
|
361
|
+
return abi
|
|
362
|
+
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
def _fetch_from_etherscan(
|
|
366
|
+
self,
|
|
367
|
+
chain_id: int,
|
|
368
|
+
address: str,
|
|
369
|
+
) -> list[dict[str, Any]] | None:
|
|
370
|
+
"""Fetch ABI from Etherscan v2 API.
|
|
371
|
+
|
|
372
|
+
Uses the unified v2 endpoint with chainid parameter.
|
|
373
|
+
Works without API key but with stricter rate limits.
|
|
374
|
+
"""
|
|
375
|
+
api_url = self._etherscan_api_url
|
|
376
|
+
|
|
377
|
+
params: dict[str, Any] = {
|
|
378
|
+
"chainid": chain_id,
|
|
379
|
+
"module": "contract",
|
|
380
|
+
"action": "getabi",
|
|
381
|
+
"address": address,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Add API key if configured (higher rate limits)
|
|
385
|
+
if self._etherscan_api_key:
|
|
386
|
+
params["apikey"] = self._etherscan_api_key
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
response = requests.get(
|
|
390
|
+
api_url,
|
|
391
|
+
params=params,
|
|
392
|
+
timeout=self._request_timeout,
|
|
393
|
+
)
|
|
394
|
+
response.raise_for_status()
|
|
395
|
+
|
|
396
|
+
data = response.json()
|
|
397
|
+
if data.get("status") == "1" and data.get("result"):
|
|
398
|
+
abi = json.loads(data["result"])
|
|
399
|
+
logger.debug(
|
|
400
|
+
"etherscan.abi_fetched",
|
|
401
|
+
address=address,
|
|
402
|
+
chain_id=chain_id,
|
|
403
|
+
)
|
|
404
|
+
return abi
|
|
405
|
+
else:
|
|
406
|
+
logger.debug(
|
|
407
|
+
"etherscan.abi_not_found",
|
|
408
|
+
address=address,
|
|
409
|
+
chain_id=chain_id,
|
|
410
|
+
message=data.get("message"),
|
|
411
|
+
)
|
|
412
|
+
except requests.RequestException as e:
|
|
413
|
+
logger.warning(
|
|
414
|
+
"etherscan.request_error",
|
|
415
|
+
address=address,
|
|
416
|
+
chain_id=chain_id,
|
|
417
|
+
error=str(e),
|
|
418
|
+
)
|
|
419
|
+
except json.JSONDecodeError as e:
|
|
420
|
+
logger.warning(
|
|
421
|
+
"etherscan.json_error",
|
|
422
|
+
address=address,
|
|
423
|
+
chain_id=chain_id,
|
|
424
|
+
error=str(e),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
def _fetch_from_sourcify(
|
|
430
|
+
self,
|
|
431
|
+
chain_id: int,
|
|
432
|
+
address: str,
|
|
433
|
+
) -> list[dict[str, Any]] | None:
|
|
434
|
+
"""Fetch ABI from Sourcify."""
|
|
435
|
+
# Try full match first
|
|
436
|
+
abi = self._fetch_sourcify_match(chain_id, address, full_match=True)
|
|
437
|
+
if abi:
|
|
438
|
+
return abi
|
|
439
|
+
|
|
440
|
+
# Try partial match
|
|
441
|
+
return self._fetch_sourcify_match(chain_id, address, full_match=False)
|
|
442
|
+
|
|
443
|
+
def _fetch_sourcify_match(
|
|
444
|
+
self,
|
|
445
|
+
chain_id: int,
|
|
446
|
+
address: str,
|
|
447
|
+
full_match: bool,
|
|
448
|
+
) -> list[dict[str, Any]] | None:
|
|
449
|
+
"""Fetch from Sourcify with specific match type."""
|
|
450
|
+
match_type = "full_match" if full_match else "partial_match"
|
|
451
|
+
url = f"https://sourcify.dev/server/repository/contracts/{match_type}/{chain_id}/{address}/metadata.json"
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
response = requests.get(url, timeout=self._request_timeout)
|
|
455
|
+
if response.status_code == 200:
|
|
456
|
+
metadata = response.json()
|
|
457
|
+
if "output" in metadata and "abi" in metadata["output"]:
|
|
458
|
+
logger.debug(
|
|
459
|
+
"sourcify.abi_fetched",
|
|
460
|
+
address=address,
|
|
461
|
+
match_type=match_type,
|
|
462
|
+
)
|
|
463
|
+
return metadata["output"]["abi"]
|
|
464
|
+
except requests.RequestException as e:
|
|
465
|
+
logger.debug("sourcify.request_error", address=address, error=str(e))
|
|
466
|
+
except json.JSONDecodeError as e:
|
|
467
|
+
logger.debug("sourcify.json_error", address=address, error=str(e))
|
|
468
|
+
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
def clear_cache(self, address: str, chain_id: int | None = None) -> bool:
|
|
472
|
+
"""Clear cached ABI and proxy resolution for an address.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
address: Contract address
|
|
476
|
+
chain_id: Chain ID (defaults to config.chain_id)
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
True if cache entry was cleared
|
|
480
|
+
"""
|
|
481
|
+
chain_id = chain_id or self.config.chain_id
|
|
482
|
+
try:
|
|
483
|
+
address = to_checksum_address(address)
|
|
484
|
+
except Exception:
|
|
485
|
+
raise InvalidAddressError(address)
|
|
486
|
+
|
|
487
|
+
abi_cleared = self.abi_cache.clear_cached_abi(chain_id, address)
|
|
488
|
+
proxy_cleared = self.abi_cache.clear_cached_proxy(chain_id, address)
|
|
489
|
+
|
|
490
|
+
return abi_cleared or proxy_cleared
|
|
491
|
+
|
|
492
|
+
def set_manual_abi(
|
|
493
|
+
self,
|
|
494
|
+
address: str,
|
|
495
|
+
abi: list[dict[str, Any]],
|
|
496
|
+
chain_id: int | None = None,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Manually set ABI for a contract.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
address: Contract address
|
|
502
|
+
abi: ABI to cache
|
|
503
|
+
chain_id: Chain ID (defaults to config.chain_id)
|
|
504
|
+
"""
|
|
505
|
+
chain_id = chain_id or self.config.chain_id
|
|
506
|
+
try:
|
|
507
|
+
address = to_checksum_address(address)
|
|
508
|
+
except Exception:
|
|
509
|
+
raise InvalidAddressError(address)
|
|
510
|
+
|
|
511
|
+
self.abi_cache.set_cached_abi(chain_id, address, json.dumps(abi), "manual")
|
|
512
|
+
logger.info("abi.manual_set", address=address, chain_id=chain_id)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def get_function_signature(name: str, inputs: list[dict[str, Any]]) -> str:
|
|
516
|
+
"""Build function signature string from name and inputs.
|
|
517
|
+
|
|
518
|
+
Example: "transfer(address,uint256)"
|
|
519
|
+
"""
|
|
520
|
+
types = [inp["type"] for inp in inputs]
|
|
521
|
+
return f"{name}({','.join(types)})"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def get_event_signature(name: str, inputs: list[dict[str, Any]]) -> str:
|
|
525
|
+
"""Build event signature string from name and inputs.
|
|
526
|
+
|
|
527
|
+
Example: "Transfer(address,address,uint256)"
|
|
528
|
+
"""
|
|
529
|
+
types = [inp["type"] for inp in inputs]
|
|
530
|
+
return f"{name}({','.join(types)})"
|