mycelium-cli 0.1.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.
@@ -0,0 +1 @@
1
+ # Mycelium CLI Package
@@ -0,0 +1 @@
1
+ # CLI subcommands package
@@ -0,0 +1,70 @@
1
+ """
2
+ `mycelium agent` — run a Mycelium agent runtime script.
3
+
4
+ Loads the developer's agent script (e.g. the scaffolded `agent.py`) as a module
5
+ and runs it, binding the on-chain contract id into the environment as
6
+ `MYCELIUM_CONTRACT_ID` so the script (and any `AgentContext` it builds) can read
7
+ it. If the script exposes a `main()` callable it is invoked; otherwise importing
8
+ the module is treated as running it (top-level code executes on import).
9
+ """
10
+
11
+ import importlib.util
12
+ import os
13
+ import sys
14
+
15
+
16
+ def _load_dotenv(path: str) -> None:
17
+ """Minimal .env loader: sets KEY=VALUE pairs into os.environ if not already set."""
18
+ if not os.path.exists(path):
19
+ return
20
+ with open(path, "r") as f:
21
+ for line in f:
22
+ line = line.strip()
23
+ if not line or line.startswith("#") or "=" not in line:
24
+ continue
25
+ key, _, value = line.partition("=")
26
+ key = key.strip()
27
+ if key.startswith("export "):
28
+ key = key[len("export "):].strip()
29
+ value = value.strip()
30
+ # Strip a single pair of matching surrounding quotes.
31
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
32
+ value = value[1:-1]
33
+ os.environ.setdefault(key, value)
34
+
35
+
36
+ def run_agent(file_path: str, contract_id: str):
37
+ if not os.path.exists(file_path):
38
+ print(f"Error: agent runtime file {file_path} not found.")
39
+ sys.exit(1)
40
+
41
+ print(f"[Agent] Loading runtime script: {file_path}")
42
+ print(f"[Agent] Bound to on-chain contract: {contract_id}")
43
+
44
+ # Expose the bound contract id to the agent script and any SDK it constructs.
45
+ os.environ["MYCELIUM_CONTRACT_ID"] = contract_id
46
+
47
+ # Load a sibling .env (written by `mycelium init` with the provider API key)
48
+ # so the agent can authenticate without the key being hard-coded in source.
49
+ _load_dotenv(os.path.join(os.path.dirname(os.path.abspath(file_path)), ".env"))
50
+
51
+ module_name = os.path.splitext(os.path.basename(file_path))[0]
52
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
53
+ if spec is None or spec.loader is None:
54
+ print(f"Error: could not load {file_path} as a Python module.")
55
+ sys.exit(1)
56
+ module = importlib.util.module_from_spec(spec)
57
+ # Make the script's own directory importable (so it can import sibling files).
58
+ sys.path.insert(0, os.path.dirname(os.path.abspath(file_path)) or ".")
59
+
60
+ try:
61
+ spec.loader.exec_module(module)
62
+ if hasattr(module, "main") and callable(module.main):
63
+ print("[Agent] Running main()...")
64
+ module.main()
65
+ print("[Agent] Runtime finished.")
66
+ except KeyboardInterrupt:
67
+ print("\n[Agent] Execution halted by user request.")
68
+ except Exception as e:
69
+ print(f"❌ Agent runtime error: {e}")
70
+ sys.exit(1)
@@ -0,0 +1,96 @@
1
+ """
2
+ `mycelium call <fn> [args...]` — invoke a deployed contract function from the CLI.
3
+
4
+ By default the call is read-only (simulated, no signature, no fee) — perfect for
5
+ views and getters. Pass `--send` to sign and submit a state-changing transaction,
6
+ which loads the project wallet and prompts for its passphrase.
7
+
8
+ Argument widths are marshalled automatically from the contract spec (fetched
9
+ once from RPC), so `mycelium call add 40` "just works" — no U64() wrapper needed.
10
+ String tokens are interpreted as ints/bools/addresses where unambiguous and left
11
+ as strings otherwise.
12
+ """
13
+
14
+ import sys
15
+ from typing import Any, List, Optional
16
+
17
+ from mycelium_cli.config import get_value
18
+
19
+ DEFAULT_WALLET_PATH = ".mycelium/wallet.json"
20
+
21
+
22
+ def _parse_arg(token: str) -> Any:
23
+ """Best-effort coerce a CLI string token into a Python value.
24
+
25
+ Integers and bools are recognised; addresses/symbols/strings are left as
26
+ strings (AgentContext + the contract spec marshal them to the right SCVal).
27
+ Prefix a value with `s:` to force it to stay a string (e.g. `s:42`).
28
+ """
29
+ if token.startswith("s:"):
30
+ return token[2:]
31
+ low = token.lower()
32
+ if low == "true":
33
+ return True
34
+ if low == "false":
35
+ return False
36
+ try:
37
+ return int(token)
38
+ except ValueError:
39
+ return token
40
+
41
+
42
+ def run_call(
43
+ function_name: str,
44
+ args: Optional[List[str]] = None,
45
+ contract: Optional[str] = None,
46
+ network: Optional[str] = None,
47
+ send: bool = False,
48
+ wallet_path: str = DEFAULT_WALLET_PATH,
49
+ passphrase: Optional[str] = None,
50
+ ) -> Any:
51
+ from mycelium_sdk import AgentContext
52
+
53
+ network = network or get_value("onchain", "network", "testnet")
54
+ contract = contract or get_value("onchain", "contract_id")
55
+ if not contract:
56
+ print(
57
+ "Error: no contract id. Pass --contract C..., or run inside a project "
58
+ "whose mycelium.toml has [onchain].contract_id (after `mycelium deploy`)."
59
+ )
60
+ sys.exit(1)
61
+
62
+ parsed = [_parse_arg(a) for a in (args or [])]
63
+
64
+ if send:
65
+ import os
66
+
67
+ if not os.path.exists(wallet_path):
68
+ print(f"Error: wallet {wallet_path} not found. Run `mycelium newwallet` first.")
69
+ sys.exit(1)
70
+ context = AgentContext(
71
+ keypair_path=wallet_path, network_type=network, passphrase=passphrase
72
+ )
73
+ else:
74
+ context = AgentContext.read_only(network_type=network)
75
+
76
+ print(
77
+ f"[call] {function_name}({', '.join(map(repr, parsed))}) on {contract} "
78
+ f"({'send' if send else 'read-only'})..."
79
+ )
80
+ try:
81
+ result = context.call_contract(
82
+ contract, function_name, parsed, read_only=not send
83
+ )
84
+ except Exception as e:
85
+ print(f"❌ Call failed: {e}")
86
+ sys.exit(1)
87
+
88
+ if send:
89
+ # call_contract returns a TxResult for state-changing calls.
90
+ tx_hash = getattr(result, "hash", None)
91
+ ret = getattr(result, "return_value", result)
92
+ print(f"✓ Submitted. Tx: {tx_hash}")
93
+ print(f" return: {ret}")
94
+ else:
95
+ print(f"✓ {result}")
96
+ return result
@@ -0,0 +1,21 @@
1
+ import os
2
+ import sys
3
+ from mycelium_compiler.parser import parse_source
4
+ from mycelium_compiler.validator import validate_ast
5
+
6
+ def run_check(file_path: str):
7
+ print(f"Checking AST rules and type constraints for: {file_path}")
8
+ if not os.path.exists(file_path):
9
+ print(f"Error: File {file_path} not found.")
10
+ sys.exit(1)
11
+
12
+ with open(file_path, "r") as f:
13
+ source_code = f.read()
14
+
15
+ try:
16
+ visitor = parse_source(source_code)
17
+ validate_ast(visitor)
18
+ print("✓ All validation checks passed successfully!")
19
+ except Exception as e:
20
+ print(f"❌ Validation failed: {e}")
21
+ sys.exit(1)
@@ -0,0 +1,59 @@
1
+ """
2
+ `mycelium compile` — compile the project's contract to Soroban WASM.
3
+
4
+ Delegates directly to the compiler pipeline (parse → validate → generate_wasm),
5
+ which builds via the pinned stellar-cli 27.0.0 toolchain.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+ from mycelium_compiler.parser import parse_source
12
+ from mycelium_compiler.validator import validate_ast
13
+ from mycelium_compiler.codegen import generate_wasm
14
+
15
+ from mycelium_cli.config import get_value
16
+
17
+
18
+ def run_compile(
19
+ file_path: str | None = None,
20
+ output_path: str | None = None,
21
+ optimize: bool = False,
22
+ ) -> str:
23
+ """
24
+ Compile `file_path` to `output_path`. When omitted, both are read from
25
+ mycelium.toml ([onchain].source_contract / [onchain].target_wasm). Prints
26
+ size telemetry. Returns the output path.
27
+ """
28
+ file_path = file_path or get_value("onchain", "source_contract", "contract.py")
29
+ output_path = output_path or get_value("onchain", "target_wasm", "build/contract.wasm")
30
+
31
+ print(f"Compiling contract {file_path} -> {output_path}...")
32
+ if not os.path.exists(file_path):
33
+ print(f"Error: File {file_path} not found.")
34
+ sys.exit(1)
35
+
36
+ with open(file_path, "r") as f:
37
+ source_code = f.read()
38
+
39
+ try:
40
+ visitor = parse_source(source_code)
41
+ validate_ast(visitor)
42
+ wasm_bytes = generate_wasm(visitor)
43
+ except Exception as e:
44
+ print(f"❌ Compilation failed: {e}")
45
+ sys.exit(1)
46
+
47
+ out_dir = os.path.dirname(output_path)
48
+ if out_dir:
49
+ os.makedirs(out_dir, exist_ok=True)
50
+ with open(output_path, "wb") as f_out:
51
+ f_out.write(wasm_bytes)
52
+
53
+ size = len(wasm_bytes)
54
+ print(f"✓ Compilation successful! Output: {output_path}")
55
+ print(f" WASM size: {size:,} bytes ({size / 1024:.2f} KiB)")
56
+ # The release profile already optimizes for size (opt-level \"z\", LTO).
57
+ if optimize:
58
+ print(" (--optimize: size-optimized release profile is always applied)")
59
+ return output_path
@@ -0,0 +1,140 @@
1
+ """
2
+ `mycelium deploy` — publish the compiled WASM to Stellar/Soroban (sdk.md 2.4).
3
+
4
+ Mirrors the proven flow in the IDE backend's /api/deploy:
5
+ - testnet: if the wallet has 0 XLM, fund it via Friendbot and poll.
6
+ - mainnet: refuse to deploy unless the wallet holds >= 5 XLM (no Friendbot).
7
+ - deploy via the pinned stellar-cli 27.0.0 binary (ensure_stellar_cli()).
8
+ - write the resulting contract_id + wallet public key back to mycelium.toml.
9
+ """
10
+
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import time
15
+
16
+ from mycelium_compiler.codegen import ensure_stellar_cli
17
+ from mycelium_sdk import crypto
18
+ from mycelium_sdk.constants import (
19
+ FRIENDBOT_URL,
20
+ HORIZON_URLS,
21
+ MAINNET_MIN_XLM,
22
+ NETWORK_PASSPHRASES,
23
+ SOROBAN_RPC_URLS,
24
+ normalize_network,
25
+ )
26
+
27
+ from mycelium_cli.config import get_value, set_value
28
+
29
+ DEFAULT_WALLET_PATH = os.path.join(".mycelium", "wallet.json")
30
+
31
+
32
+ def _load_secret(wallet_path: str, passphrase: str | None) -> tuple[str, str]:
33
+ """Decrypt the wallet, returning (secret_seed, public_key)."""
34
+ import json
35
+
36
+ with open(wallet_path, "r") as f:
37
+ wallet = json.load(f)
38
+ pw = crypto.resolve_passphrase(passphrase)
39
+ secret = crypto.decrypt_secret(
40
+ wallet["encrypted_secret"], wallet["nonce"], wallet["salt"], pw
41
+ )
42
+ return secret, wallet["public_key"]
43
+
44
+
45
+ def _native_balance(network: str, public_key: str) -> float:
46
+ """Return the account's native XLM balance, or 0.0 if the account is new."""
47
+ from stellar_sdk import Server
48
+ from stellar_sdk.exceptions import NotFoundError
49
+
50
+ server = Server(HORIZON_URLS[network])
51
+ try:
52
+ acct = server.accounts().account_id(public_key).call()
53
+ except NotFoundError:
54
+ return 0.0
55
+ for bal in acct.get("balances", []):
56
+ if bal.get("asset_type") == "native":
57
+ return float(bal["balance"])
58
+ return 0.0
59
+
60
+
61
+ def _fund_with_friendbot(network: str, public_key: str) -> None:
62
+ import requests
63
+
64
+ print(f"[deploy] Wallet has 0 XLM — requesting Friendbot funding for {public_key}...")
65
+ res = requests.get(f"{FRIENDBOT_URL}/?addr={public_key}", timeout=20)
66
+ if not res.ok:
67
+ raise RuntimeError(f"Friendbot funding failed: HTTP {res.status_code}")
68
+
69
+ # Poll the ledger until the funding actually lands (spec: "Poll until confirmed").
70
+ deadline = time.time() + 30
71
+ while time.time() < deadline:
72
+ time.sleep(3)
73
+ if _native_balance(network, public_key) > 0.0:
74
+ print("[deploy] Friendbot funding confirmed on-chain.")
75
+ return
76
+ raise RuntimeError(
77
+ "Friendbot returned success but the account is still unfunded after 30s. "
78
+ "Retry the deploy, or fund the account manually."
79
+ )
80
+
81
+
82
+ def run_deploy(
83
+ network: str = "testnet",
84
+ wasm_path: str | None = None,
85
+ wallet_path: str = DEFAULT_WALLET_PATH,
86
+ passphrase: str | None = None,
87
+ write_config: bool = True,
88
+ ) -> str:
89
+ """Deploy the compiled contract and return the new contract id."""
90
+ network = normalize_network(network)
91
+ wasm_path = wasm_path or get_value("onchain", "target_wasm", "build/contract.wasm")
92
+
93
+ if not os.path.exists(wasm_path):
94
+ print(f"Error: compiled WASM {wasm_path} not found. Run `mycelium compile` first.")
95
+ sys.exit(1)
96
+ if not os.path.exists(wallet_path):
97
+ print(f"Error: wallet {wallet_path} not found. Run `mycelium newwallet` first.")
98
+ sys.exit(1)
99
+
100
+ secret, public_key = _load_secret(wallet_path, passphrase)
101
+ print(f"[deploy] Deploying {wasm_path} to {network} as {public_key}...")
102
+
103
+ balance = _native_balance(network, public_key)
104
+ if network == "testnet":
105
+ if balance <= 0.0:
106
+ _fund_with_friendbot(network, public_key)
107
+ else:
108
+ if balance < MAINNET_MIN_XLM:
109
+ print(
110
+ f"[Error] Insufficient funds for live deployment. Mainnet operations "
111
+ f"require at least {int(MAINNET_MIN_XLM)} XLM sequence reserve. "
112
+ f"Balance must be deposited to: {public_key}."
113
+ )
114
+ sys.exit(1)
115
+
116
+ stellar_bin = ensure_stellar_cli()
117
+ cmd = [
118
+ stellar_bin, "contract", "deploy",
119
+ "--wasm", wasm_path,
120
+ "--source-account", secret,
121
+ "--rpc-url", SOROBAN_RPC_URLS[network],
122
+ "--network-passphrase", NETWORK_PASSPHRASES[network],
123
+ ]
124
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
125
+ if res.returncode != 0:
126
+ print(f"❌ Deployment failed:\n{res.stdout}\n{res.stderr}")
127
+ sys.exit(1)
128
+
129
+ contract_id = res.stdout.strip().splitlines()[-1].strip()
130
+ print(f"✓ Deployment successful! Contract ID: {contract_id}")
131
+
132
+ if write_config:
133
+ try:
134
+ set_value("onchain", "contract_id", contract_id)
135
+ set_value("onchain", "wallet_public_key", public_key)
136
+ print(" Wrote contract_id + wallet_public_key to mycelium.toml")
137
+ except FileNotFoundError:
138
+ pass # deploying outside a project dir is allowed
139
+
140
+ return contract_id
@@ -0,0 +1,67 @@
1
+ """
2
+ `mycelium agents` — discover every agent registered on the Hive Registry.
3
+
4
+ This is a read-only, wallet-free command: anyone can list the global agent
5
+ directory without owning a wallet or spending fees. It scans the registry
6
+ contract's `agent_registered` events over the RPC's retained ledger window and
7
+ resolves each name to its current address, endpoint, and reputation.
8
+
9
+ The registry address comes from `[registry].hive_registry_address` in
10
+ mycelium.toml when run inside a project, falling back to the SDK default.
11
+ """
12
+
13
+ import sys
14
+ from typing import Optional
15
+
16
+
17
+ def run_discover(
18
+ network: Optional[str] = None,
19
+ registry: Optional[str] = None,
20
+ start_ledger: Optional[int] = None,
21
+ resolve: bool = True,
22
+ ) -> list:
23
+ from mycelium_sdk import AgentContext, HiveClient
24
+ from mycelium_sdk.constants import HIVEMIND_REGISTRY_ADDRESS
25
+
26
+ from mycelium_cli.config import get_value
27
+
28
+ network = network or get_value("onchain", "network", "testnet")
29
+ registry = registry or get_value("registry", "hive_registry_address") or HIVEMIND_REGISTRY_ADDRESS
30
+
31
+ context = AgentContext.read_only(network_type=network)
32
+ hive = HiveClient(context, registry_address=registry)
33
+
34
+ print(f"[discover] Scanning Hive Registry {registry} on {network}...")
35
+ try:
36
+ agents = hive.discover_agents(start_ledger=start_ledger, resolve=resolve)
37
+ except Exception as e:
38
+ print(f"❌ Discovery failed: {e}")
39
+ sys.exit(1)
40
+
41
+ _print_agents(agents)
42
+ return agents
43
+
44
+
45
+ def _print_agents(agents: list) -> None:
46
+ if not agents:
47
+ print(
48
+ "No agents found in the registry's retained event window.\n"
49
+ " (Registrations older than the RPC's retention horizon are not "
50
+ "discoverable — pass --start-ledger to widen the scan.)"
51
+ )
52
+ return
53
+
54
+ print(f"\nFound {len(agents)} agent(s):\n")
55
+ name_w = max(len("NAME"), *(len(a.get("name", "")) for a in agents))
56
+ addr_w = max(len("ADDRESS"), *(len(a.get("public_key") or "") for a in agents))
57
+ header = f" {'NAME':<{name_w}} {'ADDRESS':<{addr_w}} {'REP':>4} ENDPOINT"
58
+ print(header)
59
+ print(" " + "-" * (len(header) - 2))
60
+ for a in agents:
61
+ name = a.get("name", "")
62
+ addr = a.get("public_key") or "—"
63
+ rep = a.get("reputation")
64
+ rep_str = str(rep) if rep is not None else "—"
65
+ endpoint = a.get("endpoint") or "—"
66
+ print(f" {name:<{name_w}} {addr:<{addr_w}} {rep_str:>4} {endpoint}")
67
+ print()
@@ -0,0 +1,106 @@
1
+ """
2
+ `mycelium doctor` — verify the local toolchain and print fixes.
3
+
4
+ Checks the things that silently break a compile/deploy:
5
+ - stellar-cli present and at the version Mycelium pins (we hit a 25-vs-27
6
+ mismatch in the field),
7
+ - a Rust toolchain with the wasm32 target installed,
8
+ - the Soroban RPC for the configured network is reachable.
9
+
10
+ Each failed check prints the exact command that fixes it. Exit code is non-zero
11
+ if anything is wrong, so it can gate CI.
12
+ """
13
+
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from typing import Optional
19
+
20
+ from mycelium_sdk.constants import SOROBAN_RPC_URLS, normalize_network
21
+
22
+ from mycelium_cli.config import get_value
23
+
24
+ # Must track compiler/.../core.py::ensure_stellar_cli (the version we bundle).
25
+ PINNED_STELLAR_VERSION = "27.0.0"
26
+ WASM_TARGET = "wasm32-unknown-unknown"
27
+
28
+ _OK = "✓"
29
+ _NO = "✗"
30
+ _WARN = "⚠"
31
+
32
+
33
+ def _run(cmd: list[str]) -> Optional[str]:
34
+ """Run `cmd`, returning stripped stdout, or None if it isn't runnable."""
35
+ try:
36
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
37
+ except (FileNotFoundError, subprocess.SubprocessError):
38
+ return None
39
+ if res.returncode != 0:
40
+ return None
41
+ return (res.stdout or res.stderr).strip()
42
+
43
+
44
+ def _check_stellar_cli() -> bool:
45
+ binary = shutil.which("stellar")
46
+ if not binary:
47
+ print(f" {_NO} stellar-cli not found on PATH")
48
+ print(f" fix: cargo install --locked stellar-cli@{PINNED_STELLAR_VERSION}")
49
+ return False
50
+ out = _run(["stellar", "--version"]) or ""
51
+ m = re.search(r"(\d+)\.(\d+)\.(\d+)", out)
52
+ version = m.group(0) if m else "unknown"
53
+ major = int(m.group(1)) if m else 0
54
+ pinned_major = int(PINNED_STELLAR_VERSION.split(".")[0])
55
+ if major == pinned_major:
56
+ print(f" {_OK} stellar-cli {version} ({binary})")
57
+ return True
58
+ print(f" {_WARN} stellar-cli {version} — Mycelium pins v{PINNED_STELLAR_VERSION}")
59
+ print(f" fix: cargo install --locked stellar-cli@{PINNED_STELLAR_VERSION}")
60
+ return False
61
+
62
+
63
+ def _check_rust() -> bool:
64
+ rustc = _run(["rustc", "--version"])
65
+ if not rustc:
66
+ print(f" {_NO} rust rustc not found")
67
+ print(" fix: curl https://sh.rustup.rs -sSf | sh")
68
+ return False
69
+ print(f" {_OK} rust {rustc}")
70
+
71
+ installed = _run(["rustup", "target", "list", "--installed"]) or ""
72
+ if WASM_TARGET in installed:
73
+ print(f" {_OK} wasm target {WASM_TARGET} installed")
74
+ return True
75
+ print(f" {_NO} wasm target {WASM_TARGET} missing")
76
+ print(f" fix: rustup target add {WASM_TARGET}")
77
+ return False
78
+
79
+
80
+ def _check_rpc(network: str) -> bool:
81
+ url = SOROBAN_RPC_URLS[network]
82
+ try:
83
+ from stellar_sdk import SorobanServer
84
+
85
+ seq = SorobanServer(url).get_latest_ledger().sequence
86
+ print(f" {_OK} rpc {network} reachable (ledger {seq})")
87
+ return True
88
+ except Exception as e:
89
+ print(f" {_NO} rpc {network} unreachable: {e}")
90
+ print(f" fix: check connectivity to {url}")
91
+ return False
92
+
93
+
94
+ def run_doctor(network: Optional[str] = None) -> bool:
95
+ network = normalize_network(network or get_value("onchain", "network", "testnet"))
96
+ print("\nMycelium doctor — toolchain check\n")
97
+ results = [
98
+ _check_stellar_cli(),
99
+ _check_rust(),
100
+ _check_rpc(network),
101
+ ]
102
+ ok = all(results)
103
+ print(f"\n{'✓ All checks passed.' if ok else '✗ Some checks failed — see fixes above.'}\n")
104
+ if not ok:
105
+ sys.exit(1)
106
+ return ok
@@ -0,0 +1,116 @@
1
+ """
2
+ `mycelium events` — show (or stream) a contract's on-chain events from RPC.
3
+
4
+ Defaults to the project's deployed contract (`[onchain].contract_id`). It scans
5
+ the RPC's retained ledger window and prints each event's topics + value decoded
6
+ to native Python. With `--follow` it polls for new events and prints them as they
7
+ land, until interrupted.
8
+
9
+ Soroban RPC only retains events for a bounded number of ledgers; registrations or
10
+ calls older than that horizon are not visible here.
11
+ """
12
+
13
+ import sys
14
+ import time
15
+ from typing import Optional
16
+
17
+ from mycelium_cli.config import get_value
18
+
19
+ _PAGE_LIMIT = 100
20
+ _LEDGER_WINDOW = 16000
21
+ _FOLLOW_INTERVAL_SECONDS = 4
22
+
23
+
24
+ def _decode(event, scval, stellar_xdr):
25
+ """Decode an event's topics and value to native Python, tolerating bad XDR."""
26
+ def _native(x):
27
+ try:
28
+ return scval.to_native(stellar_xdr.SCVal.from_xdr(x))
29
+ except (ValueError, AttributeError):
30
+ return "<undecodable>"
31
+
32
+ topics = [_native(t) for t in (event.topic or [])]
33
+ value = _native(event.value) if event.value else None
34
+ return topics, value
35
+
36
+
37
+ def _print_event(event, scval, stellar_xdr) -> None:
38
+ topics, value = _decode(event, scval, stellar_xdr)
39
+ ledger = getattr(event, "ledger", "?")
40
+ topic_str = ", ".join(map(str, topics)) or "—"
41
+ print(f" [ledger {ledger}] {topic_str} -> {value}")
42
+
43
+
44
+ def run_events(
45
+ contract: Optional[str] = None,
46
+ network: Optional[str] = None,
47
+ start_ledger: Optional[int] = None,
48
+ follow: bool = False,
49
+ ) -> None:
50
+ from mycelium_sdk import AgentContext
51
+ from stellar_sdk import scval
52
+ from stellar_sdk import xdr as stellar_xdr
53
+ from stellar_sdk.soroban_rpc import EventFilter, EventFilterType
54
+
55
+ network = network or get_value("onchain", "network", "testnet")
56
+ contract = contract or get_value("onchain", "contract_id")
57
+ if not contract:
58
+ print("Error: no contract id. Pass --contract C..., or deploy first.")
59
+ sys.exit(1)
60
+
61
+ rpc = AgentContext.read_only(network_type=network).soroban_rpc
62
+ event_filter = EventFilter(
63
+ event_type=EventFilterType.CONTRACT,
64
+ contract_ids=[contract],
65
+ topics=[["*"]],
66
+ )
67
+
68
+ latest = rpc.get_latest_ledger().sequence
69
+ if start_ledger is None:
70
+ probe = rpc.get_events(start_ledger=max(1, latest - 1), filters=[event_filter], limit=1)
71
+ start_ledger = probe.oldest_ledger or max(1, latest - _LEDGER_WINDOW)
72
+
73
+ print(f"[events] Scanning {contract} on {network} from ledger {start_ledger}...\n")
74
+ cursor = _scan(rpc, event_filter, start_ledger, latest, scval, stellar_xdr)
75
+
76
+ if not follow:
77
+ return
78
+
79
+ print("\n[events] Following new events (Ctrl-C to stop)...")
80
+ try:
81
+ while True:
82
+ time.sleep(_FOLLOW_INTERVAL_SECONDS)
83
+ page = rpc.get_events(cursor=cursor, filters=[event_filter], limit=_PAGE_LIMIT)
84
+ for event in page.events or []:
85
+ _print_event(event, scval, stellar_xdr)
86
+ cursor = event.id
87
+ except KeyboardInterrupt:
88
+ print("\n[events] Stopped.")
89
+
90
+
91
+ def _scan(rpc, event_filter, lo, latest, scval, stellar_xdr) -> Optional[str]:
92
+ """Print every event from `lo`..`latest`, returning the last cursor seen."""
93
+ cursor = None
94
+ count = 0
95
+ while lo <= latest:
96
+ hi = min(lo + _LEDGER_WINDOW - 1, latest)
97
+ page_cursor = None
98
+ while True:
99
+ kwargs = {"filters": [event_filter], "limit": _PAGE_LIMIT}
100
+ if page_cursor is None:
101
+ kwargs["start_ledger"] = lo
102
+ kwargs["end_ledger"] = hi
103
+ else:
104
+ kwargs["cursor"] = page_cursor
105
+ page = rpc.get_events(**kwargs)
106
+ events = page.events or []
107
+ for event in events:
108
+ _print_event(event, scval, stellar_xdr)
109
+ page_cursor = cursor = event.id
110
+ count += 1
111
+ if len(events) < _PAGE_LIMIT:
112
+ break
113
+ lo = hi + 1
114
+ if count == 0:
115
+ print(" (no events in the retained window)")
116
+ return cursor