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.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,112 @@
1
+ """Health check command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from brawny.cli.helpers import get_db, print_json
12
+
13
+
14
+ @click.command()
15
+ @click.option("--format", "fmt", default="json", help="Output format (json or text)")
16
+ @click.option("--config", "config_path", default=None, help="Path to config.yaml")
17
+ def health(fmt: str, config_path: str | None) -> None:
18
+ """Health check endpoint."""
19
+ from brawny.config import Config, get_config
20
+ from brawny._rpc import RPCManager
21
+
22
+ try:
23
+ if config_path:
24
+ config = Config.from_yaml(config_path)
25
+ config, _ = config.apply_env_overrides()
26
+ else:
27
+ config = get_config()
28
+ except Exception as e:
29
+ if fmt == "json":
30
+ print_json({"status": "unhealthy", "error": f"Config error: {e}"})
31
+ else:
32
+ click.echo(f"UNHEALTHY: Config error: {e}")
33
+ sys.exit(1)
34
+
35
+ db = get_db(config_path)
36
+ result: dict[str, Any] = {
37
+ "status": "healthy",
38
+ "timestamp": datetime.now(timezone.utc).isoformat(),
39
+ "components": {},
40
+ }
41
+
42
+ try:
43
+ db_stats = db.get_database_stats()
44
+ result["components"]["database"] = {
45
+ "status": "ok",
46
+ "type": db_stats.get("type", "unknown"),
47
+ }
48
+
49
+ try:
50
+ rpc = RPCManager.from_config(config)
51
+ rpc_health = rpc.get_health()
52
+ result["components"]["rpc"] = {
53
+ "status": "ok" if rpc_health["healthy_endpoints"] > 0 else "degraded",
54
+ "healthy_endpoints": rpc_health["healthy_endpoints"],
55
+ "total_endpoints": rpc_health["total_endpoints"],
56
+ "circuit_breaker_open": rpc_health["circuit_breaker_open"],
57
+ }
58
+
59
+ try:
60
+ block_number = rpc.get_block_number()
61
+ chain_id = rpc.get_chain_id()
62
+ result["components"]["chain"] = {
63
+ "chain_id": chain_id,
64
+ "head_block": block_number,
65
+ }
66
+ except Exception as e:
67
+ result["components"]["chain"] = {
68
+ "status": "error",
69
+ "error": str(e)[:100],
70
+ }
71
+ result["status"] = "degraded"
72
+ except Exception as e:
73
+ result["components"]["rpc"] = {
74
+ "status": "error",
75
+ "error": str(e)[:100],
76
+ }
77
+ result["status"] = "degraded"
78
+
79
+ block_states = db_stats.get("block_states", [])
80
+ if block_states and "chain" in result["components"]:
81
+ result["components"]["chain"]["last_processed_block"] = block_states[0].get(
82
+ "last_block"
83
+ )
84
+
85
+ intents_by_status = db_stats.get("intents_by_status", {})
86
+ result["intents"] = {
87
+ "pending": intents_by_status.get("pending", 0),
88
+ "claimed": intents_by_status.get("claimed", 0),
89
+ "created": intents_by_status.get("created", 0),
90
+ }
91
+
92
+ except Exception as e:
93
+ result["status"] = "unhealthy"
94
+ result["error"] = str(e)[:200]
95
+ finally:
96
+ db.close()
97
+
98
+ if fmt == "json":
99
+ print_json(result)
100
+ else:
101
+ click.echo(f"Status: {result['status'].upper()}")
102
+ click.echo(f"Timestamp: {result['timestamp']}")
103
+ for name, component in result.get("components", {}).items():
104
+ status = component.get("status", "unknown")
105
+ click.echo(f" {name}: {status}")
106
+
107
+ if result["status"] != "healthy":
108
+ sys.exit(1)
109
+
110
+
111
+ def register(main) -> None:
112
+ main.add_command(health)
@@ -0,0 +1,86 @@
1
+ """Project initialization command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from brawny.cli.helpers import (
10
+ _check_directory_empty,
11
+ _write_agents,
12
+ _print_success,
13
+ _validate_project_name,
14
+ _write_config,
15
+ _write_env_example,
16
+ _write_examples,
17
+ _write_gitignore,
18
+ _write_monitoring,
19
+ _write_pyproject,
20
+ )
21
+
22
+
23
+ @click.command()
24
+ @click.argument("project_name", required=False, default=None)
25
+ @click.option("--force", "-f", is_flag=True, help="Initialize even if directory is not empty")
26
+ def init(project_name: str | None, force: bool) -> None:
27
+ """Initialize a new brawny project in the current directory.
28
+
29
+ PROJECT_NAME defaults to the current directory name if not provided.
30
+
31
+ Examples:
32
+
33
+ brawny init # Use current directory name
34
+
35
+ brawny init my_keeper # Use 'my_keeper' as project name
36
+
37
+ brawny init -f # Force init in non-empty directory
38
+ """
39
+ project_dir = Path.cwd()
40
+
41
+ if project_name is None:
42
+ project_name = project_dir.name
43
+
44
+ _validate_project_name(project_name) # Validates name is usable
45
+
46
+ if not force:
47
+ _check_directory_empty(project_dir)
48
+
49
+ try:
50
+ # Normalize project name to valid Python package name
51
+ package_name = project_name.lower().replace("-", "_").replace(" ", "_")
52
+
53
+ # Create package_name, jobs, interfaces, and data structure
54
+ package_dir = project_dir / package_name
55
+ jobs_dir = project_dir / "jobs"
56
+ interfaces_dir = project_dir / "interfaces"
57
+ data_dir = project_dir / "data"
58
+ package_dir.mkdir(parents=True, exist_ok=True)
59
+ jobs_dir.mkdir(parents=True, exist_ok=True)
60
+ interfaces_dir.mkdir(parents=True, exist_ok=True)
61
+ data_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ # Create __init__.py files
64
+ (package_dir / "__init__.py").touch()
65
+ (jobs_dir / "__init__.py").write_text(
66
+ '"""Job definitions - auto-discovered from ./jobs."""\n'
67
+ )
68
+
69
+ _write_pyproject(project_dir, project_name, package_name)
70
+ _write_config(project_dir, package_name)
71
+ _write_env_example(project_dir)
72
+ _write_agents(project_dir)
73
+ _write_gitignore(project_dir)
74
+ _write_examples(jobs_dir / "_examples.py")
75
+ _write_monitoring(project_dir)
76
+
77
+ except click.ClickException:
78
+ raise
79
+ except Exception as exc:
80
+ raise click.ClickException(str(exc)) from exc
81
+
82
+ _print_success(project_name, package_name)
83
+
84
+
85
+ def register(main) -> None:
86
+ main.add_command(init)
@@ -0,0 +1,130 @@
1
+ """Transaction intent commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from brawny.cli.helpers import get_db
10
+
11
+
12
+ @click.group()
13
+ def intents() -> None:
14
+ """Manage transaction intents."""
15
+ pass
16
+
17
+
18
+ @intents.command("list")
19
+ @click.option("--status", help="Filter by status")
20
+ @click.option("--job", help="Filter by job ID")
21
+ @click.option("--limit", default=50, help="Limit results")
22
+ @click.option("--config", "config_path", default=None, help="Path to config.yaml")
23
+ def intents_list(status: str | None, job: str | None, limit: int, config_path: str | None) -> None:
24
+ """List transaction intents."""
25
+ db = get_db(config_path)
26
+ try:
27
+ intents_data = db.list_intents_filtered(status=status, job_id=job, limit=limit)
28
+ if not intents_data:
29
+ click.echo("No intents found.")
30
+ return
31
+
32
+ click.echo("\nTransaction Intents:")
33
+ click.echo("-" * 120)
34
+ click.echo(f"{'Intent ID':<38} {'Job':<20} {'Status':<12} {'Created':<20} {'Retry After':<20}")
35
+ click.echo("-" * 120)
36
+ for intent in intents_data:
37
+ intent_id = str(intent["intent_id"])[:36]
38
+ job_id = intent["job_id"][:18]
39
+ created = str(intent["created_at"])[:19]
40
+ retry_after = str(intent.get("retry_after") or "")[:19]
41
+ click.echo(f"{intent_id:<38} {job_id:<20} {intent['status']:<12} {created:<20} {retry_after:<20}")
42
+ click.echo(f"\n(Showing {len(intents_data)} of {limit} max)")
43
+ finally:
44
+ db.close()
45
+
46
+
47
+ @intents.command("show")
48
+ @click.argument("intent_id")
49
+ @click.option("--config", "config_path", default=None, help="Path to config.yaml")
50
+ def intents_show(intent_id: str, config_path: str | None) -> None:
51
+ """Show intent details."""
52
+ from uuid import UUID
53
+
54
+ db = get_db(config_path)
55
+ try:
56
+ intent = db.get_intent(UUID(intent_id))
57
+ if not intent:
58
+ click.echo(f"Intent '{intent_id}' not found.", err=True)
59
+ sys.exit(1)
60
+
61
+ click.echo(f"\nIntent: {intent.intent_id}")
62
+ click.echo("-" * 60)
63
+ click.echo(f" Job ID: {intent.job_id}")
64
+ click.echo(f" Chain ID: {intent.chain_id}")
65
+ click.echo(f" Status: {intent.status.value}")
66
+ click.echo(f" Signer: {intent.signer_address}")
67
+ click.echo(f" To: {intent.to_address}")
68
+ click.echo(f" Value: {intent.value_wei} wei")
69
+ click.echo(f" Idempotency Key: {intent.idempotency_key}")
70
+ click.echo(f" Min Confirmations: {intent.min_confirmations}")
71
+ if intent.deadline_ts:
72
+ click.echo(f" Deadline: {intent.deadline_ts}")
73
+ if intent.retry_after:
74
+ click.echo(f" Retry After: {intent.retry_after}")
75
+ if intent.retry_count > 0:
76
+ click.echo(f" Retry Count: {intent.retry_count}")
77
+ if intent.claim_token:
78
+ click.echo(f" Claim Token: {intent.claim_token}")
79
+ click.echo(f" Created: {intent.created_at}")
80
+ click.echo(f" Updated: {intent.updated_at}")
81
+
82
+ attempts = db.get_attempts_for_intent(intent.intent_id)
83
+ if attempts:
84
+ click.echo(f"\n Attempts ({len(attempts)}):")
85
+ for att in attempts:
86
+ status = att.status.value
87
+ tx_hash = att.tx_hash[:20] + "..." if att.tx_hash else "N/A"
88
+ click.echo(f" - {att.attempt_id}: {status} (tx: {tx_hash})")
89
+ click.echo()
90
+ finally:
91
+ db.close()
92
+
93
+
94
+ @intents.command("cancel")
95
+ @click.argument("intent_id")
96
+ @click.option("--force", is_flag=True, help="Force cancel even if in-flight")
97
+ @click.option("--config", "config_path", default=None, help="Path to config.yaml")
98
+ def intents_cancel(intent_id: str, force: bool, config_path: str | None) -> None:
99
+ """Cancel an intent."""
100
+ from uuid import UUID
101
+
102
+ db = get_db(config_path)
103
+ try:
104
+ intent = db.get_intent(UUID(intent_id))
105
+ if not intent:
106
+ click.echo(f"Intent '{intent_id}' not found.", err=True)
107
+ sys.exit(1)
108
+
109
+ if intent.status.value in ("confirmed", "abandoned"):
110
+ click.echo(f"Intent is already {intent.status.value}.", err=True)
111
+ sys.exit(1)
112
+
113
+ if intent.status.value in ("sending", "pending") and not force:
114
+ click.echo(
115
+ f"Intent is {intent.status.value}. Use --force to cancel in-flight intents.",
116
+ err=True,
117
+ )
118
+ sys.exit(1)
119
+
120
+ if db.abandon_intent(UUID(intent_id)):
121
+ click.echo(f"Intent '{intent_id}' cancelled.")
122
+ else:
123
+ click.echo("Failed to cancel intent.", err=True)
124
+ sys.exit(1)
125
+ finally:
126
+ db.close()
127
+
128
+
129
+ def register(main) -> None:
130
+ main.add_command(intents)
@@ -0,0 +1,254 @@
1
+ """Developer utilities for individual jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from brawny.cli.helpers import print_json
11
+
12
+
13
+ @click.group()
14
+ def job() -> None:
15
+ """Developer utilities for a single job."""
16
+ pass
17
+
18
+
19
+ @job.command("run")
20
+ @click.argument("job_id")
21
+ @click.option("--at-block", type=int, help="Run check/build against this block")
22
+ @click.option("--dry-run", is_flag=True, help="Run check only (skip build_intent)")
23
+ @click.option(
24
+ "--jobs-module",
25
+ "jobs_modules",
26
+ multiple=True,
27
+ help="Additional job module(s) to load",
28
+ )
29
+ @click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
30
+ def job_run(
31
+ job_id: str,
32
+ at_block: int | None,
33
+ dry_run: bool,
34
+ jobs_modules: tuple[str, ...],
35
+ config_path: str,
36
+ ) -> None:
37
+ """Run check/build for a single job without sending transactions."""
38
+ import time
39
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
40
+
41
+ from brawny._context import _job_ctx, _current_job
42
+ from brawny.config import Config
43
+ from brawny.db import create_database
44
+ from brawny.alerts.contracts import ContractSystem
45
+ from brawny.jobs.kv import DatabaseJobKVStore
46
+ from brawny.jobs.registry import get_registry
47
+ from brawny.jobs.discovery import auto_discover_jobs, discover_jobs
48
+ from brawny.logging import get_logger, setup_logging
49
+ from brawny.model.enums import LogFormat
50
+ from brawny.model.types import BlockInfo, JobContext, idempotency_key
51
+ from brawny._rpc import RPCManager
52
+ from brawny.scripting import set_job_context
53
+
54
+ if not config_path or not os.path.exists(config_path):
55
+ click.echo(
56
+ f"Config file is required for job run and was not found: {config_path}",
57
+ err=True,
58
+ )
59
+ sys.exit(1)
60
+
61
+ config = Config.from_yaml(config_path)
62
+ config, overrides = config.apply_env_overrides()
63
+
64
+ log_level = os.environ.get("BRAWNY_LOG_LEVEL", "INFO")
65
+ setup_logging(log_level, LogFormat.JSON, config.chain_id)
66
+ log = get_logger(__name__)
67
+ log.info(
68
+ "config.loaded",
69
+ path=config_path,
70
+ overrides=overrides,
71
+ config=config.redacted_dict(),
72
+ )
73
+
74
+ db = create_database(
75
+ config.database_url,
76
+ pool_size=config.database_pool_size,
77
+ pool_max_overflow=config.database_pool_max_overflow,
78
+ pool_timeout=config.database_pool_timeout_seconds,
79
+ circuit_breaker_failures=config.db_circuit_breaker_failures,
80
+ circuit_breaker_seconds=config.db_circuit_breaker_seconds,
81
+ )
82
+ db.connect()
83
+
84
+ try:
85
+ rpc = RPCManager.from_config(config)
86
+ contract_system = ContractSystem(rpc, config)
87
+
88
+ modules = list(jobs_modules)
89
+ if modules:
90
+ discover_jobs(modules)
91
+ else:
92
+ auto_discover_jobs()
93
+
94
+ registry = get_registry()
95
+ job_obj = registry.get(job_id)
96
+ if job_obj is None:
97
+ click.echo(f"Job '{job_id}' not found. Did you register it?", err=True)
98
+ sys.exit(1)
99
+
100
+ if at_block is not None:
101
+ block_number = at_block
102
+ else:
103
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
104
+ number_future = executor.submit(rpc.get_block_number, timeout=5)
105
+ try:
106
+ block_number = number_future.result(timeout=10)
107
+ except FuturesTimeout:
108
+ click.echo("Timed out fetching latest block number.", err=True)
109
+ sys.exit(1)
110
+ block_hash = "0x0"
111
+ timestamp = 0
112
+
113
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
114
+ block_future = executor.submit(rpc.get_block, block_number, False, 5)
115
+ try:
116
+ block_data = block_future.result(timeout=10)
117
+ except FuturesTimeout:
118
+ block_data = None
119
+
120
+ if block_data:
121
+ block_hash = block_data.get("hash", "")
122
+ if hasattr(block_hash, "hex"):
123
+ block_hash = f"0x{block_hash.hex()}"
124
+ timestamp = block_data.get("timestamp", 0)
125
+ else:
126
+ click.echo(
127
+ f"Warning: timed out loading block {block_number}; using fallback data.",
128
+ err=True,
129
+ )
130
+
131
+ block = BlockInfo(
132
+ chain_id=config.chain_id,
133
+ block_number=block_number,
134
+ block_hash=block_hash,
135
+ timestamp=timestamp,
136
+ )
137
+
138
+ job_config = db.get_job(job_id)
139
+ ctx = JobContext(
140
+ block=block,
141
+ rpc=rpc,
142
+ logger=log.bind(job_id=job_id),
143
+ state={},
144
+ job_config=job_config,
145
+ kv=DatabaseJobKVStore(db, job_id),
146
+ job_id=job_id,
147
+ contract_system=contract_system,
148
+ hook_name="check",
149
+ )
150
+
151
+ def _call_check():
152
+ # Set contextvars for implicit context
153
+ ctx_token = _job_ctx.set(ctx)
154
+ job_token = _current_job.set(job_obj)
155
+ set_job_context(True)
156
+ try:
157
+ return job_obj.check()
158
+ finally:
159
+ set_job_context(False)
160
+ _job_ctx.reset(ctx_token)
161
+ _current_job.reset(job_token)
162
+
163
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
164
+ start = time.time()
165
+ future = executor.submit(_call_check)
166
+ try:
167
+ trigger = future.result(timeout=job_obj.check_timeout_seconds)
168
+ except FuturesTimeout:
169
+ click.echo(
170
+ f"check() timed out after {job_obj.check_timeout_seconds}s",
171
+ err=True,
172
+ )
173
+ sys.exit(1)
174
+
175
+ elapsed = time.time() - start
176
+
177
+ if not trigger:
178
+ click.echo(f"No trigger (check completed in {elapsed:.2f}s).")
179
+ return
180
+
181
+ idem_parts = list(trigger.idempotency_parts) or [block.block_number]
182
+ idem_key = idempotency_key(job_id, *idem_parts)
183
+
184
+ click.echo("\nTrigger:")
185
+ print_json(
186
+ {
187
+ "reason": trigger.reason,
188
+ "tx_required": trigger.tx_required,
189
+ "idempotency_parts": idem_parts,
190
+ "idempotency_key": idem_key,
191
+ "check_seconds": round(elapsed, 3),
192
+ }
193
+ )
194
+
195
+ if dry_run or not trigger.tx_required:
196
+ if dry_run:
197
+ click.echo("Dry run enabled; skipping build_intent.")
198
+ return
199
+
200
+ ctx_build = JobContext(
201
+ block=block,
202
+ rpc=rpc,
203
+ logger=log.bind(job_id=job_id),
204
+ state={"trigger": trigger},
205
+ job_config=job_config,
206
+ kv=DatabaseJobKVStore(db, job_id),
207
+ job_id=job_id,
208
+ contract_system=contract_system,
209
+ hook_name="build_intent",
210
+ )
211
+
212
+ def _call_build():
213
+ # Set contextvars for implicit context
214
+ ctx_token = _job_ctx.set(ctx_build)
215
+ job_token = _current_job.set(job_obj)
216
+ set_job_context(True)
217
+ try:
218
+ return job_obj.build_intent(trigger)
219
+ finally:
220
+ set_job_context(False)
221
+ _job_ctx.reset(ctx_token)
222
+ _current_job.reset(job_token)
223
+
224
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
225
+ future = executor.submit(_call_build)
226
+ try:
227
+ spec = future.result(timeout=job_obj.build_timeout_seconds)
228
+ except FuturesTimeout:
229
+ click.echo(
230
+ f"build_intent() timed out after {job_obj.build_timeout_seconds}s",
231
+ err=True,
232
+ )
233
+ sys.exit(1)
234
+
235
+ click.echo("\nTxIntentSpec:")
236
+ print_json(
237
+ {
238
+ "signer_address": spec.signer_address,
239
+ "to_address": spec.to_address,
240
+ "data": spec.data,
241
+ "value_wei": spec.value_wei,
242
+ "gas_limit": spec.gas_limit,
243
+ "max_fee_per_gas": spec.max_fee_per_gas,
244
+ "max_priority_fee_per_gas": spec.max_priority_fee_per_gas,
245
+ "min_confirmations": spec.min_confirmations,
246
+ "deadline_seconds": spec.deadline_seconds,
247
+ }
248
+ )
249
+ finally:
250
+ db.close()
251
+
252
+
253
+ def register(main) -> None:
254
+ main.add_command(job)