Skip to content

Commit f04e922

Browse files
authored
Merge branch 'master' into rube/heartbeat-block
2 parents fcfae1c + 5c1d5ff commit f04e922

7 files changed

Lines changed: 389 additions & 57 deletions

File tree

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ ROFL_ADAPTER_ADDRESS=YOUR_ROFL_ADAPTER_ADDRESS # ROFLAdapter contract address o
55
REQUEST_TIMEOUT=30 # HTTP request timeout in seconds
66
RETRY_COUNT=3 # Number of retry attempts for failed operations
77

8-
# Oracle mode: event_listener, push, or watcher
8+
# Oracle mode: event_listener, push, watcher, or token_watcher
99
ORACLE_MODE=event_listener
1010

1111
# Logging configuration
@@ -28,6 +28,13 @@ WATCHER_BATCH_SIZE=50 # Max blocks to scan per iteration
2828
# LOOKBACK_BLOCKS=100 # Also used in watcher mode
2929
ENABLE_INTERNAL_TX_DETECTION=false # Enable internal transaction detection (requires archive node with debug API)
3030

31+
# Token Watcher Mode Configuration (ORACLE_MODE=token_watcher)
32+
TOKEN_ADDRESSES=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # Comma-separated ERC20 token contract addresses (e.g., USDC, USDT)
33+
RECIPIENT_ADDRESSES=0xYourAddress1,0xYourAddress2 # Comma-separated recipient addresses to watch for
34+
SCAN_INTERVAL=5 # Seconds between scans (default: 5 for fast detection)
35+
# Note: On first startup, begins monitoring from current block (no historical scan)
36+
# Note: Scans all new blocks since last check (no batch size limit)
37+
3138
# Local mode configuration (for testing without ROFL)
3239
# Only required when running with --local flag or compose.local.yaml
3340
LOCAL_PRIVATE_KEY=0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef

compose.local.yaml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ services:
66
image: ghcr.io/rube-de/rofl-header-oracle:latest
77
platform: linux/amd64
88
container_name: rofl_header_oracle
9-
entrypoint: /bin/sh -c 'uv run python -m rofl_oracle --local --log-level DEBUG'
9+
entrypoint: /bin/sh -c 'uv run python -m rofl_oracle --local'
1010
ports:
1111
- "8080:8080" # Health check endpoint
1212
environment:
@@ -20,12 +20,12 @@ services:
2020
REQUEST_TIMEOUT: ${REQUEST_TIMEOUT:-30} # HTTP request timeout in seconds
2121
RETRY_COUNT: ${RETRY_COUNT:-3} # Number of retry attempts
2222

23-
# Oracle mode: event_listener, push, or watcher
23+
# Oracle mode: event_listener, push, watcher, or token_watcher
2424
ORACLE_MODE: ${ORACLE_MODE:-event_listener}
2525

2626
# Logging configuration
27-
LOG_LEVEL: ${LOG_LEVEL:-DEBUG} # DEBUG mode for local testing
28-
JSON_LOGS: ${JSON_LOGS:-false} # Enable JSON-formatted structured logging
27+
LOG_LEVEL: ${LOG_LEVEL:-INFO}
28+
JSON_LOGS: ${JSON_LOGS:-false}
2929

3030
# Event Listener Mode Configuration (ORACLE_MODE=event_listener)
3131
SOURCE_CONTRACT_ADDRESS: ${SOURCE_CONTRACT_ADDRESS:-} # Required for event_listener mode
@@ -42,8 +42,12 @@ services:
4242
WATCHER_BATCH_SIZE: ${WATCHER_BATCH_SIZE:-50} # Max blocks to scan per iteration
4343
ENABLE_INTERNAL_TX_DETECTION: ${ENABLE_INTERNAL_TX_DETECTION:-false} # Requires archive node with debug API
4444

45+
# Token Watcher Mode Configuration (ORACLE_MODE=token_watcher)
46+
TOKEN_ADDRESSES: ${TOKEN_ADDRESSES:-} # Required for token_watcher mode - comma-separated token contract addresses
47+
RECIPIENT_ADDRESSES: ${RECIPIENT_ADDRESSES:-} # Required for token_watcher mode - comma-separated recipient addresses
48+
4549
# Local mode configuration (for testing without ROFL)
4650
LOCAL_PRIVATE_KEY: ${LOCAL_PRIVATE_KEY:-} # Required in local mode - private key for testing
47-
51+
4852
restart: on-failure
4953
# Note: No ROFL socket volume mount needed in local mode

compose.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ services:
4040
SCAN_INTERVAL: ${SCAN_INTERVAL} # Seconds between scanning for interactions
4141
WATCHER_BATCH_SIZE: ${WATCHER_BATCH_SIZE} # Max blocks to scan per iteration
4242
ENABLE_INTERNAL_TX_DETECTION: ${ENABLE_INTERNAL_TX_DETECTION:-false} # Requires archive node with debug API
43-
43+
44+
# Token Watcher Mode Configuration (ORACLE_MODE=token_watcher)
45+
TOKEN_ADDRESSES: ${TOKEN_ADDRESSES:-} # Required for token_watcher mode - comma-separated token contract addresses
46+
RECIPIENT_ADDRESSES: ${RECIPIENT_ADDRESSES:-} # Required for token_watcher mode - comma-separated recipient addresses
47+
4448
restart: on-failure
4549
volumes:
4650
- /run/rofl-appd.sock:/run/rofl-appd.sock

rofl_oracle/config.py

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
class OracleMode(Enum):
2121
"""Oracle operating modes."""
2222

23-
EVENT_LISTENER = "event_listener" # Listen for BlockHeaderRequested events
24-
PUSH = "push" # Push latest block headers periodically
25-
WATCHER = "watcher" # Watch specific addresses for interactions
23+
EVENT_LISTENER = "event_listener"
24+
PUSH = "push"
25+
WATCHER = "watcher"
26+
TOKEN_WATCHER = "token_watcher"
2627

2728

2829
@dataclass(frozen=True, slots=True)
@@ -202,6 +203,71 @@ def __post_init__(self) -> None:
202203
object.__setattr__(self, "watch_addresses", checksummed_addresses)
203204

204205

206+
@dataclass(frozen=True, slots=True)
207+
class TokenWatcherModeConfig:
208+
"""Configuration specific to token watcher mode.
209+
210+
This mode watches for ERC20 token transfers to specific recipient addresses
211+
and pushes block headers when such transfers are detected.
212+
213+
:cvar token_addresses: List of ERC20 token contract addresses to monitor (env: TOKEN_ADDRESSES, comma-separated)
214+
:cvar recipient_addresses: List of recipient addresses to watch for (env: RECIPIENT_ADDRESSES, comma-separated)
215+
:cvar scan_interval: Seconds between scanning for token transfers (env: SCAN_INTERVAL)
216+
:cvar max_blocks_per_scan: Maximum number of blocks to scan per iteration (env: MAX_BLOCKS_PER_SCAN, default: 10)
217+
"""
218+
219+
token_addresses: list[str]
220+
recipient_addresses: list[str]
221+
scan_interval: int
222+
max_blocks_per_scan: int = 10
223+
224+
def __post_init__(self) -> None:
225+
"""Validate token watcher configuration."""
226+
if self.scan_interval <= 0:
227+
raise ValueError(
228+
f"Scan interval must be positive, got {self.scan_interval}"
229+
)
230+
if self.scan_interval > 300:
231+
raise ValueError(
232+
f"Scan interval too long (max 300s), got {self.scan_interval}"
233+
)
234+
235+
if self.max_blocks_per_scan <= 0:
236+
raise ValueError(
237+
f"Max blocks per scan must be positive, got {self.max_blocks_per_scan}"
238+
)
239+
if self.max_blocks_per_scan > 10000:
240+
raise ValueError(
241+
f"Max blocks per scan too high (max 10000), got {self.max_blocks_per_scan}"
242+
)
243+
244+
if not self.token_addresses or len(self.token_addresses) == 0:
245+
raise ValueError(
246+
"Token watcher mode requires at least one token address"
247+
)
248+
249+
if not self.recipient_addresses or len(self.recipient_addresses) == 0:
250+
raise ValueError(
251+
"Token watcher mode requires at least one recipient address"
252+
)
253+
254+
# Validate and checksum token addresses
255+
checksummed_tokens = []
256+
for addr in self.token_addresses:
257+
if not Web3.is_address(addr):
258+
raise ValueError(f"Invalid token address: {addr}")
259+
checksummed_tokens.append(Web3.to_checksum_address(addr))
260+
object.__setattr__(self, "token_addresses", checksummed_tokens)
261+
262+
# Validate and checksum recipient addresses
263+
checksummed_recipients = []
264+
for addr in self.recipient_addresses:
265+
if not Web3.is_address(addr):
266+
raise ValueError(f"Invalid recipient address: {addr}")
267+
checksummed_recipients.append(Web3.to_checksum_address(addr))
268+
object.__setattr__(self, "recipient_addresses", checksummed_recipients)
269+
270+
205271
@dataclass(frozen=True, slots=True)
206272
class CommonConfig:
207273
"""Configuration common to all oracle modes.
@@ -294,14 +360,19 @@ class OracleConfig:
294360
295361
:cvar common_config: Common configuration shared across all modes
296362
:cvar oracle_mode: The operating mode of the oracle
297-
:cvar mode_config: Mode-specific configuration (EventListenerModeConfig, PushModeConfig, or WatcherModeConfig)
363+
:cvar mode_config: Mode-specific configuration (EventListenerModeConfig, PushModeConfig, WatcherModeConfig, or TokenWatcherModeConfig)
298364
:cvar local_mode: Whether running in local mode (for testing)
299365
:cvar local_private_key: Private key for local mode (optional)
300366
"""
301367

302368
common_config: CommonConfig
303369
oracle_mode: OracleMode
304-
mode_config: EventListenerModeConfig | PushModeConfig | WatcherModeConfig
370+
mode_config: (
371+
EventListenerModeConfig
372+
| PushModeConfig
373+
| WatcherModeConfig
374+
| TokenWatcherModeConfig
375+
)
305376
local_mode: bool = False
306377
local_private_key: str | None = None
307378

@@ -346,6 +417,12 @@ def __post_init__(self) -> None:
346417
self.mode_config, WatcherModeConfig
347418
):
348419
raise ValueError("Watcher mode requires WatcherModeConfig")
420+
elif self.oracle_mode == OracleMode.TOKEN_WATCHER and not isinstance(
421+
self.mode_config, TokenWatcherModeConfig
422+
):
423+
raise ValueError(
424+
"Token watcher mode requires TokenWatcherModeConfig"
425+
)
349426

350427
@classmethod
351428
def from_env(cls, local_mode: bool = False) -> "OracleConfig":
@@ -395,15 +472,20 @@ def from_env(cls, local_mode: bool = False) -> "OracleConfig":
395472
oracle_mode = OracleMode.PUSH
396473
elif mode_str == "watcher":
397474
oracle_mode = OracleMode.WATCHER
475+
elif mode_str == "token_watcher":
476+
oracle_mode = OracleMode.TOKEN_WATCHER
398477
else:
399478
raise ValueError(
400479
f"Invalid ORACLE_MODE: {mode_str}. "
401-
"Must be one of: event_listener, push, watcher"
480+
"Must be one of: event_listener, push, watcher, token_watcher"
402481
)
403482

404483
# Load mode-specific configuration
405484
mode_config: (
406-
EventListenerModeConfig | PushModeConfig | WatcherModeConfig
485+
EventListenerModeConfig
486+
| PushModeConfig
487+
| WatcherModeConfig
488+
| TokenWatcherModeConfig
407489
)
408490

409491
if oracle_mode == OracleMode.EVENT_LISTENER:
@@ -417,7 +499,7 @@ def from_env(cls, local_mode: bool = False) -> "OracleConfig":
417499
push_interval=int(os.environ.get("PUSH_INTERVAL", "60")),
418500
batch_size=int(os.environ.get("PUSH_BATCH_SIZE", "20")),
419501
)
420-
else: # OracleMode.WATCHER
502+
elif oracle_mode == OracleMode.WATCHER:
421503
watch_addresses_str = os.environ.get("WATCH_ADDRESSES", "")
422504
if not watch_addresses_str:
423505
raise ValueError(
@@ -450,6 +532,46 @@ def from_env(cls, local_mode: bool = False) -> "OracleConfig":
450532
os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "3600")
451533
),
452534
)
535+
else: # OracleMode.TOKEN_WATCHER
536+
token_addresses_str = os.environ.get("TOKEN_ADDRESSES", "")
537+
if not token_addresses_str:
538+
raise ValueError(
539+
"TOKEN_ADDRESSES is required for token watcher mode. "
540+
"Provide comma-separated token contract addresses (e.g., USDC, USDT)."
541+
)
542+
543+
recipient_addresses_str = os.environ.get("RECIPIENT_ADDRESSES", "")
544+
if not recipient_addresses_str:
545+
raise ValueError(
546+
"RECIPIENT_ADDRESSES is required for token watcher mode. "
547+
"Provide comma-separated recipient addresses to watch."
548+
)
549+
550+
# Parse comma-separated token addresses
551+
token_addresses = [
552+
addr.strip()
553+
for addr in token_addresses_str.split(",")
554+
if addr.strip()
555+
]
556+
557+
if len(token_addresses) == 0:
558+
raise ValueError("TOKEN_ADDRESSES cannot be empty")
559+
560+
# Parse comma-separated recipient addresses
561+
recipient_addresses = [
562+
addr.strip()
563+
for addr in recipient_addresses_str.split(",")
564+
if addr.strip()
565+
]
566+
567+
if len(recipient_addresses) == 0:
568+
raise ValueError("RECIPIENT_ADDRESSES cannot be empty")
569+
570+
mode_config = TokenWatcherModeConfig(
571+
token_addresses=token_addresses,
572+
recipient_addresses=recipient_addresses,
573+
scan_interval=int(os.environ.get("SCAN_INTERVAL", "5")),
574+
)
453575

454576
# Load oracle-level config
455577
local_private_key = (
@@ -527,6 +649,26 @@ def log_config(self) -> None:
527649
for addr in self.mode_config.watch_addresses:
528650
logger.info(f" - {addr}")
529651

652+
elif self.oracle_mode == OracleMode.TOKEN_WATCHER:
653+
assert isinstance(self.mode_config, TokenWatcherModeConfig)
654+
logger.info("Token Watcher Settings:")
655+
logger.info(
656+
f" Scan Interval: {self.mode_config.scan_interval} seconds"
657+
)
658+
logger.info(
659+
" Mode: Real-time (scans all new blocks since last check)"
660+
)
661+
logger.info(
662+
f" Watching {len(self.mode_config.token_addresses)} token(s):"
663+
)
664+
for addr in self.mode_config.token_addresses:
665+
logger.info(f" - {addr}")
666+
logger.info(
667+
f" Monitoring {len(self.mode_config.recipient_addresses)} recipient(s):"
668+
)
669+
for addr in self.mode_config.recipient_addresses:
670+
logger.info(f" - {addr}")
671+
530672
logger.info("Oracle Settings:")
531673
logger.info(f" Mode: {'LOCAL' if self.local_mode else 'PRODUCTION'}")
532674

0 commit comments

Comments
 (0)