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.
- mycelium_cli/__init__.py +1 -0
- mycelium_cli/commands/__init__.py +1 -0
- mycelium_cli/commands/agent.py +70 -0
- mycelium_cli/commands/call.py +96 -0
- mycelium_cli/commands/check.py +21 -0
- mycelium_cli/commands/compile.py +59 -0
- mycelium_cli/commands/deploy.py +140 -0
- mycelium_cli/commands/discover.py +67 -0
- mycelium_cli/commands/doctor.py +106 -0
- mycelium_cli/commands/events.py +116 -0
- mycelium_cli/commands/fund.py +63 -0
- mycelium_cli/commands/init.py +188 -0
- mycelium_cli/commands/newwallet.py +61 -0
- mycelium_cli/commands/pay.py +83 -0
- mycelium_cli/commands/register.py +59 -0
- mycelium_cli/commands/resolve.py +54 -0
- mycelium_cli/commands/run.py +33 -0
- mycelium_cli/commands/status.py +127 -0
- mycelium_cli/commands/test.py +72 -0
- mycelium_cli/config.py +47 -0
- mycelium_cli/main.py +298 -0
- mycelium_cli-0.1.0.dist-info/METADATA +239 -0
- mycelium_cli-0.1.0.dist-info/RECORD +26 -0
- mycelium_cli-0.1.0.dist-info/WHEEL +5 -0
- mycelium_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mycelium_cli-0.1.0.dist-info/top_level.txt +1 -0
mycelium_cli/__init__.py
ADDED
|
@@ -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
|