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,209 @@
1
+ """Script execution command (brownie-compatible).
2
+
3
+ Usage:
4
+ brawny run scripts/harvest.py
5
+ brawny run scripts/harvest.py main --arg1 value1
6
+ brawny run scripts/deploy.py --fork
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import click
17
+
18
+ from brawny.logging import get_logger
19
+
20
+ log = get_logger(__name__)
21
+
22
+
23
+ @click.command("run")
24
+ @click.argument("script_path", type=click.Path(exists=True))
25
+ @click.argument("function", default="main")
26
+ @click.argument("args", nargs=-1)
27
+ @click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
28
+ @click.option("--fork", is_flag=True, help="Fork network with Anvil")
29
+ @click.option("--fork-block", type=int, help="Fork at specific block")
30
+ @click.option("--port", default=8545, type=int, help="Port for Anvil fork")
31
+ @click.option("--interactive", "-i", is_flag=True, help="Drop into console after script")
32
+ def run(
33
+ script_path: str,
34
+ function: str,
35
+ args: tuple[str, ...],
36
+ config_path: str,
37
+ fork: bool,
38
+ fork_block: int | None,
39
+ port: int,
40
+ interactive: bool,
41
+ ):
42
+ """Run a Python script with brawny context (brownie-compatible).
43
+
44
+ Scripts have access to:
45
+
46
+ accounts - Signing accounts from keystore
47
+
48
+ Contract() - Contract interaction
49
+
50
+ chain - Block information
51
+
52
+ history - Transaction history
53
+
54
+ Wei() - Unit conversion
55
+
56
+ web3, rpc - Direct RPC access
57
+
58
+ Example:
59
+
60
+ brawny run scripts/harvest.py
61
+
62
+ brawny run scripts/deploy.py deploy --fork
63
+
64
+ brawny run scripts/debug.py --interactive
65
+ """
66
+ from brawny.alerts.contracts import ContractSystem
67
+ from brawny.config import Config
68
+ from brawny.keystore import create_keystore
69
+ from brawny._rpc import RPCManager
70
+ from brawny.accounts import _init_accounts
71
+ from brawny.history import _init_history
72
+ from brawny.chain import _init_chain
73
+ from brawny.script_tx import _init_broadcaster
74
+ from brawny.api import _set_fallback_rpc
75
+ from brawny._context import _job_ctx
76
+ from brawny.model.types import JobContext, BlockInfo
77
+ from brawny.logging import setup_logging
78
+
79
+ # Setup logging
80
+ setup_logging(log_level="INFO")
81
+
82
+ if not os.path.exists(config_path):
83
+ click.echo(f"Config file not found: {config_path}", err=True)
84
+ sys.exit(1)
85
+
86
+ config = Config.from_yaml(config_path)
87
+ config, _ = config.apply_env_overrides()
88
+
89
+ from brawny.config.routing import resolve_default_group
90
+
91
+ default_group = resolve_default_group(config)
92
+ rpc_endpoints = config.rpc_groups[default_group].endpoints
93
+ chain_id = config.chain_id
94
+
95
+ if not rpc_endpoints:
96
+ click.echo("No RPC endpoints configured", err=True)
97
+ sys.exit(1)
98
+
99
+ if fork:
100
+ # Start Anvil fork
101
+ from brawny.cli.commands.console import _start_anvil_fork
102
+ local_url = _start_anvil_fork(rpc_endpoints[0], chain_id, port, block=fork_block)
103
+ rpc_endpoints = [local_url]
104
+ click.echo(f"Started Anvil fork at {local_url}")
105
+
106
+ # Create RPC manager
107
+ rpc = RPCManager(
108
+ endpoints=rpc_endpoints,
109
+ timeout_seconds=config.rpc_timeout_seconds,
110
+ max_retries=config.rpc_max_retries,
111
+ )
112
+ _set_fallback_rpc(rpc)
113
+
114
+ # Create keystore for signing
115
+ keystore = create_keystore(
116
+ config.keystore_type,
117
+ keystore_path=config.keystore_path,
118
+ allowed_signers=[],
119
+ )
120
+
121
+ # Create contract system
122
+ contract_system = ContractSystem(rpc, config)
123
+
124
+ # Initialize script singletons
125
+ _init_accounts()
126
+ _init_history()
127
+ _init_chain(rpc, chain_id)
128
+ _init_broadcaster(rpc, keystore, chain_id)
129
+
130
+ # Set up job context for Contract() to work
131
+ block_data = rpc.get_block("latest")
132
+ block = BlockInfo(
133
+ chain_id=chain_id,
134
+ block_number=block_data["number"],
135
+ block_hash=block_data["hash"],
136
+ timestamp=block_data["timestamp"],
137
+ )
138
+ ctx = JobContext(
139
+ block=block,
140
+ rpc=rpc,
141
+ logger=log,
142
+ contract_system=contract_system,
143
+ hook_name="script",
144
+ )
145
+ _job_ctx.set(ctx)
146
+
147
+ # Load and run script
148
+ script_file = Path(script_path).resolve()
149
+ sys.path.insert(0, str(script_file.parent))
150
+
151
+ try:
152
+ # Load module
153
+ spec = importlib.util.spec_from_file_location("__script__", script_file)
154
+ if spec is None or spec.loader is None:
155
+ raise click.ClickException(f"Cannot load script: {script_path}")
156
+
157
+ module = importlib.util.module_from_spec(spec)
158
+ sys.modules["__script__"] = module
159
+ spec.loader.exec_module(module)
160
+
161
+ # Find and call function
162
+ if not hasattr(module, function):
163
+ available = [n for n in dir(module) if not n.startswith("_") and callable(getattr(module, n))]
164
+ raise click.ClickException(
165
+ f"Function '{function}' not found in {script_path}. "
166
+ f"Available: {', '.join(available)}"
167
+ )
168
+
169
+ func = getattr(module, function)
170
+
171
+ # Parse args (simple key=value style)
172
+ kwargs = {}
173
+ positional = []
174
+ for arg in args:
175
+ if "=" in arg:
176
+ key, value = arg.split("=", 1)
177
+ kwargs[key] = value
178
+ else:
179
+ positional.append(arg)
180
+
181
+ # Execute
182
+ log.info("script.run", script=script_path, function=function)
183
+ result = func(*positional, **kwargs)
184
+
185
+ if result is not None:
186
+ click.echo(f"Result: {result}")
187
+
188
+ log.info("script.complete")
189
+
190
+ # Interactive mode: drop into console with script namespace
191
+ if interactive:
192
+ click.echo("\nDropping into interactive console (script namespace available)...")
193
+ try:
194
+ from brawny.cli.commands.console import _start_repl
195
+ # Make script module's namespace available
196
+ namespace = {name: getattr(module, name) for name in dir(module) if not name.startswith("_")}
197
+ _start_repl(namespace)
198
+ except ImportError:
199
+ click.echo("Interactive mode requires prompt_toolkit. Install with: pip install prompt_toolkit", err=True)
200
+
201
+ finally:
202
+ sys.path.remove(str(script_file.parent))
203
+ if "__script__" in sys.modules:
204
+ del sys.modules["__script__"]
205
+
206
+
207
+ def register(main) -> None:
208
+ """Register run command with main CLI (brownie-compatible script runner)."""
209
+ main.add_command(run)
@@ -0,0 +1,248 @@
1
+ """Signer management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from brawny.cli.helpers import get_db
8
+
9
+
10
+ def resolve_signer_address(db, chain_id: int, address_or_alias: str) -> str:
11
+ """Resolve address or alias to address.
12
+
13
+ Args:
14
+ db: Database connection
15
+ chain_id: Chain ID for lookup
16
+ address_or_alias: Either a 0x address or an alias string
17
+
18
+ Returns:
19
+ Lowercase address
20
+
21
+ Raises:
22
+ click.ClickException: If alias not found
23
+ """
24
+ # If it looks like an address, use directly
25
+ if address_or_alias.startswith("0x") and len(address_or_alias) == 42:
26
+ return address_or_alias.lower()
27
+
28
+ # Otherwise, look up alias in signers table
29
+ signer = db.get_signer_by_alias(chain_id, address_or_alias)
30
+ if signer is None:
31
+ raise click.ClickException(f"Unknown signer alias: {address_or_alias}")
32
+ return signer.signer_address
33
+
34
+
35
+ @click.group()
36
+ def signer() -> None:
37
+ """Signer management commands."""
38
+ pass
39
+
40
+
41
+ @signer.command("force-reset")
42
+ @click.argument("address_or_alias")
43
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
44
+ @click.option("--config", "config_path", default=None, help="Path to config.yaml")
45
+ def force_reset(address_or_alias: str, yes: bool, config_path: str | None) -> None:
46
+ """Force reset nonce state for a signer. USE WITH CAUTION.
47
+
48
+ ADDRESS_OR_ALIAS can be:
49
+ - Full address: 0x1234567890abcdef1234567890abcdef12345678
50
+ - Signer alias: hot-wallet-1
51
+
52
+ This will:
53
+ - Query current chain pending nonce
54
+ - Reset local next_nonce to match chain
55
+ - Release all reservations with nonce >= chain_pending_nonce
56
+ - Clear gap tracking
57
+
58
+ WARNING: If any prior transactions later mine, you may have duplicate
59
+ transactions or nonce conflicts.
60
+ """
61
+ from web3 import Web3
62
+
63
+ from brawny.config import Config, get_config
64
+ from brawny.db import create_database
65
+ from brawny._rpc import RPCManager
66
+ from brawny.tx.nonce import NonceManager
67
+ from brawny.model.enums import NonceStatus
68
+
69
+ # Load config
70
+ if config_path:
71
+ config = Config.from_yaml(config_path)
72
+ config, _ = config.apply_env_overrides()
73
+ else:
74
+ config = get_config()
75
+
76
+ # Connect to database
77
+ db = create_database(
78
+ config.database_url,
79
+ pool_size=config.database_pool_size,
80
+ pool_max_overflow=config.database_pool_max_overflow,
81
+ pool_timeout=config.database_pool_timeout_seconds,
82
+ circuit_breaker_failures=config.db_circuit_breaker_failures,
83
+ circuit_breaker_seconds=config.db_circuit_breaker_seconds,
84
+ )
85
+ db.connect()
86
+
87
+ try:
88
+ # Resolve address or alias
89
+ address = resolve_signer_address(db, config.chain_id, address_or_alias)
90
+
91
+ # Setup RPC and nonce manager
92
+ rpc = RPCManager.from_config(config)
93
+ nonce_manager = NonceManager(db, rpc, config.chain_id)
94
+
95
+ # Get current state
96
+ chain_pending = rpc.get_transaction_count(
97
+ Web3.to_checksum_address(address), block_identifier="pending"
98
+ )
99
+ signer_state = db.get_signer_state(config.chain_id, address)
100
+ reservations = db.get_reservations_for_signer(config.chain_id, address)
101
+
102
+ # Calculate affected reservations
103
+ affected = [
104
+ r for r in reservations
105
+ if r.nonce >= chain_pending and r.status in (NonceStatus.RESERVED, NonceStatus.IN_FLIGHT)
106
+ ]
107
+
108
+ # Display current state
109
+ click.echo(f"\nSigner: {address}")
110
+ if address_or_alias.lower() != address:
111
+ click.echo(f"Alias: {address_or_alias}")
112
+ click.echo(f"Current chain pending nonce: {chain_pending}")
113
+ click.echo(f"Current local next_nonce: {signer_state.next_nonce if signer_state else 'N/A'}")
114
+ click.echo(f"Reservations to release: {len(affected)}")
115
+
116
+ if affected:
117
+ click.echo("\nReservations that will be released:")
118
+ for r in affected:
119
+ click.echo(f" - nonce {r.nonce}: {r.status.value} (intent: {r.intent_id})")
120
+
121
+ click.echo("\n" + click.style(
122
+ "WARNING: This may cause duplicate transactions if prior txs later mine!",
123
+ fg="yellow", bold=True
124
+ ))
125
+
126
+ # Confirm if not --yes
127
+ if not yes:
128
+ if not click.confirm("\nProceed with force reset?"):
129
+ click.echo("Aborted.")
130
+ return
131
+
132
+ # Execute force reset
133
+ new_nonce = nonce_manager.force_reset(address)
134
+ click.echo(click.style(
135
+ f"\n✓ Reset complete. next_nonce now {new_nonce}",
136
+ fg="green", bold=True
137
+ ))
138
+
139
+ finally:
140
+ db.close()
141
+
142
+
143
+ @signer.command("status")
144
+ @click.argument("address_or_alias")
145
+ @click.option("--config", "config_path", default=None, help="Path to config.yaml")
146
+ def status(address_or_alias: str, config_path: str | None) -> None:
147
+ """Show nonce status for a signer.
148
+
149
+ ADDRESS_OR_ALIAS can be:
150
+ - Full address: 0x1234567890abcdef1234567890abcdef12345678
151
+ - Signer alias: hot-wallet-1
152
+ """
153
+ from web3 import Web3
154
+
155
+ from brawny.config import Config, get_config
156
+ from brawny.db import create_database
157
+ from brawny._rpc import RPCManager
158
+ from brawny.model.enums import NonceStatus
159
+
160
+ # Load config
161
+ if config_path:
162
+ config = Config.from_yaml(config_path)
163
+ config, _ = config.apply_env_overrides()
164
+ else:
165
+ config = get_config()
166
+
167
+ # Connect to database
168
+ db = create_database(
169
+ config.database_url,
170
+ pool_size=config.database_pool_size,
171
+ pool_max_overflow=config.database_pool_max_overflow,
172
+ pool_timeout=config.database_pool_timeout_seconds,
173
+ circuit_breaker_failures=config.db_circuit_breaker_failures,
174
+ circuit_breaker_seconds=config.db_circuit_breaker_seconds,
175
+ )
176
+ db.connect()
177
+
178
+ try:
179
+ # Resolve address or alias
180
+ address = resolve_signer_address(db, config.chain_id, address_or_alias)
181
+
182
+ # Setup RPC
183
+ rpc = RPCManager.from_config(config)
184
+
185
+ # Get current state
186
+ chain_pending = rpc.get_transaction_count(
187
+ Web3.to_checksum_address(address), block_identifier="pending"
188
+ )
189
+ chain_confirmed = rpc.get_transaction_count(
190
+ Web3.to_checksum_address(address), block_identifier="latest"
191
+ )
192
+ signer_state = db.get_signer_state(config.chain_id, address)
193
+ reservations = db.get_reservations_for_signer(config.chain_id, address)
194
+
195
+ # Display status
196
+ click.echo(f"\nSigner: {address}")
197
+ if address_or_alias.lower() != address:
198
+ click.echo(f"Alias: {address_or_alias}")
199
+
200
+ click.echo(f"\nChain State:")
201
+ click.echo(f" Confirmed nonce (latest): {chain_confirmed}")
202
+ click.echo(f" Pending nonce: {chain_pending}")
203
+
204
+ if signer_state:
205
+ click.echo(f"\nLocal State:")
206
+ click.echo(f" next_nonce: {signer_state.next_nonce}")
207
+ click.echo(f" last_synced_chain_nonce: {signer_state.last_synced_chain_nonce}")
208
+ if signer_state.gap_started_at:
209
+ click.echo(click.style(
210
+ f" gap_started_at: {signer_state.gap_started_at} (BLOCKED)",
211
+ fg="red"
212
+ ))
213
+ else:
214
+ click.echo(f"\nLocal State: Not initialized")
215
+
216
+ # Check for gap
217
+ active = [r for r in reservations if r.status not in (NonceStatus.RELEASED,)]
218
+ if active:
219
+ expected_next = min(r.nonce for r in active)
220
+ if chain_pending < expected_next:
221
+ gap = expected_next - chain_pending
222
+ click.echo(click.style(
223
+ f"\n⚠️ NONCE GAP DETECTED: chain_pending ({chain_pending}) < expected ({expected_next}), gap={gap}",
224
+ fg="yellow", bold=True
225
+ ))
226
+
227
+ # Show reservations
228
+ if reservations:
229
+ click.echo(f"\nReservations ({len(reservations)} total):")
230
+ for r in sorted(reservations, key=lambda x: x.nonce):
231
+ status_color = {
232
+ NonceStatus.RESERVED: "yellow",
233
+ NonceStatus.IN_FLIGHT: "cyan",
234
+ NonceStatus.RELEASED: "green",
235
+ NonceStatus.ORPHANED: "red",
236
+ }.get(r.status, "white")
237
+ click.echo(f" nonce {r.nonce}: " + click.style(r.status.value, fg=status_color) +
238
+ f" (intent: {str(r.intent_id)[:8] if r.intent_id else 'none'})")
239
+ else:
240
+ click.echo(f"\nNo reservations")
241
+
242
+ finally:
243
+ db.close()
244
+
245
+
246
+ def register(main) -> None:
247
+ """Register signer commands with the main CLI."""
248
+ main.add_command(signer)