spacerouter-cli 0.2.2__tar.gz → 0.3.0b1__tar.gz

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 (28) hide show
  1. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/PKG-INFO +2 -2
  2. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/pyproject.toml +2 -2
  3. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/__init__.py +1 -1
  4. spacerouter_cli-0.3.0b1/src/spacerouter_cli/commands/escrow.py +236 -0
  5. spacerouter_cli-0.3.0b1/src/spacerouter_cli/commands/receipts.py +76 -0
  6. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/main.py +6 -1
  7. spacerouter_cli-0.3.0b1/tests/test_escrow_cli.py +152 -0
  8. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/.gitignore +0 -0
  9. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/__init__.py +0 -0
  10. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/api_key.py +0 -0
  11. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/billing.py +0 -0
  12. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/config_cmd.py +0 -0
  13. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/dashboard.py +0 -0
  14. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/node.py +0 -0
  15. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/request.py +0 -0
  16. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/commands/status.py +0 -0
  17. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/config.py +0 -0
  18. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/src/spacerouter_cli/output.py +0 -0
  19. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/__init__.py +0 -0
  20. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/conftest.py +0 -0
  21. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_api_key.py +0 -0
  22. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_billing.py +0 -0
  23. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_config_cmd.py +0 -0
  24. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_dashboard.py +0 -0
  25. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_integration.py +0 -0
  26. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_node.py +0 -0
  27. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_request.py +0 -0
  28. {spacerouter_cli-0.2.2 → spacerouter_cli-0.3.0b1}/tests/test_status.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spacerouter-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0b1
4
4
  Summary: CLI for the Space Router residential proxy network — designed for AI agents
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: httpx<1.0,>=0.27
8
8
  Requires-Dist: rich<14.0,>=13.0
9
- Requires-Dist: spacerouter>=0.2.0
9
+ Requires-Dist: spacerouter>=0.3.0b1
10
10
  Requires-Dist: typer<1.0,>=0.12
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "spacerouter-cli"
7
- version = "0.2.2"
7
+ version = "0.3.0b1"
8
8
  description = "CLI for the Space Router residential proxy network — designed for AI agents"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
11
11
  dependencies = [
12
12
  "typer>=0.12,<1.0",
13
13
  "httpx>=0.27,<1.0",
14
- "spacerouter>=0.2.0",
14
+ "spacerouter>=0.3.0b1",
15
15
  "rich>=13.0,<14.0",
16
16
  ]
17
17
 
@@ -1,3 +1,3 @@
1
1
  """SpaceRouter CLI — AI-agent-friendly tool for residential proxy requests."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0b1"
@@ -0,0 +1,236 @@
1
+ """``spacerouter escrow`` — TokenPaymentEscrow wallet operations.
2
+
3
+ Consumer-facing view of on-chain balance and withdrawal state. JSON
4
+ output only (agent-friendly). Read operations work without a private
5
+ key; deposits / withdrawals require one via ``--private-key`` or
6
+ ``SR_ESCROW_PRIVATE_KEY``.
7
+
8
+ This command group operates against the on-chain contract directly.
9
+ For a provider's *local* receipt state (signed/failed/retryable),
10
+ see the provider CLI at
11
+ ``python -m app.main --receipts`` on the node.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Annotated, Optional
18
+
19
+ import typer
20
+
21
+ from spacerouter.escrow import EscrowClient
22
+ from spacerouter_cli.output import cli_error_handler, print_json
23
+
24
+ app = typer.Typer(
25
+ help="Query and interact with the TokenPaymentEscrow contract on-chain.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+
30
+ ENV_RPC = "SR_ESCROW_CHAIN_RPC"
31
+ ENV_CONTRACT = "SR_ESCROW_CONTRACT_ADDRESS"
32
+ ENV_PRIVATE_KEY = "SR_ESCROW_PRIVATE_KEY"
33
+
34
+
35
+ RpcOpt = Annotated[
36
+ Optional[str],
37
+ typer.Option(
38
+ "--rpc-url",
39
+ help=(
40
+ "Creditcoin RPC endpoint. Env: SR_ESCROW_CHAIN_RPC. "
41
+ "Default test: https://rpc.cc3-testnet.creditcoin.network"
42
+ ),
43
+ ),
44
+ ]
45
+ ContractOpt = Annotated[
46
+ Optional[str],
47
+ typer.Option(
48
+ "--contract-address",
49
+ help="TokenPaymentEscrow proxy address. Env: SR_ESCROW_CONTRACT_ADDRESS.",
50
+ ),
51
+ ]
52
+ PrivateKeyOpt = Annotated[
53
+ Optional[str],
54
+ typer.Option(
55
+ "--private-key",
56
+ help=(
57
+ "Wallet private key for write operations. "
58
+ "Env: SR_ESCROW_PRIVATE_KEY. Never log or commit."
59
+ ),
60
+ ),
61
+ ]
62
+
63
+
64
+ def _resolve_client(
65
+ rpc_url: Optional[str],
66
+ contract_address: Optional[str],
67
+ private_key: Optional[str] = None,
68
+ ) -> EscrowClient:
69
+ rpc = rpc_url or os.environ.get(ENV_RPC)
70
+ contract = contract_address or os.environ.get(ENV_CONTRACT)
71
+ key = private_key or os.environ.get(ENV_PRIVATE_KEY)
72
+
73
+ if not rpc:
74
+ raise typer.BadParameter(
75
+ "Missing RPC URL. Use --rpc-url or set SR_ESCROW_CHAIN_RPC.",
76
+ )
77
+ if not contract:
78
+ raise typer.BadParameter(
79
+ "Missing contract address. Use --contract-address or set "
80
+ "SR_ESCROW_CONTRACT_ADDRESS.",
81
+ )
82
+ return EscrowClient(
83
+ rpc_url=rpc, contract_address=contract, private_key=key,
84
+ )
85
+
86
+
87
+ @app.command("balance")
88
+ @cli_error_handler
89
+ def balance(
90
+ address: Annotated[
91
+ str, typer.Argument(help="Address to query escrow balance for."),
92
+ ],
93
+ rpc_url: RpcOpt = None,
94
+ contract_address: ContractOpt = None,
95
+ ) -> None:
96
+ """Escrow balance for an address, in wei (18-decimal SPACE)."""
97
+ client = _resolve_client(rpc_url, contract_address)
98
+ wei = client.balance(address)
99
+ print_json({
100
+ "address": address,
101
+ "escrow_balance_wei": wei,
102
+ "escrow_balance_space": wei / 10**18,
103
+ })
104
+
105
+
106
+ @app.command("token-balance")
107
+ @cli_error_handler
108
+ def token_balance(
109
+ address: Annotated[
110
+ str, typer.Argument(help="Address to query undeposited token balance for."),
111
+ ],
112
+ rpc_url: RpcOpt = None,
113
+ contract_address: ContractOpt = None,
114
+ ) -> None:
115
+ """Undeposited (wallet-held) SPACE token balance in wei."""
116
+ client = _resolve_client(rpc_url, contract_address)
117
+ wei = client.token_balance(address)
118
+ print_json({
119
+ "address": address,
120
+ "token_balance_wei": wei,
121
+ "token_balance_space": wei / 10**18,
122
+ })
123
+
124
+
125
+ @app.command("withdrawal-request")
126
+ @cli_error_handler
127
+ def withdrawal_request(
128
+ address: Annotated[
129
+ str, typer.Argument(help="Address whose pending withdrawal to inspect."),
130
+ ],
131
+ rpc_url: RpcOpt = None,
132
+ contract_address: ContractOpt = None,
133
+ ) -> None:
134
+ """Pending withdrawal state: amount + unlock timestamp."""
135
+ client = _resolve_client(rpc_url, contract_address)
136
+ amount, unlock_at, exists = client.withdrawal_request(address)
137
+ print_json({
138
+ "address": address,
139
+ "has_pending_withdrawal": exists,
140
+ "amount_wei": amount,
141
+ "amount_space": amount / 10**18,
142
+ "unlock_at_epoch_seconds": unlock_at,
143
+ })
144
+
145
+
146
+ @app.command("withdrawal-delay")
147
+ @cli_error_handler
148
+ def withdrawal_delay(
149
+ rpc_url: RpcOpt = None,
150
+ contract_address: ContractOpt = None,
151
+ ) -> None:
152
+ """Contract-wide withdrawal delay in seconds."""
153
+ client = _resolve_client(rpc_url, contract_address)
154
+ delay = client.withdrawal_delay()
155
+ print_json({
156
+ "withdrawal_delay_seconds": delay,
157
+ "withdrawal_delay_days": delay / 86400,
158
+ })
159
+
160
+
161
+ @app.command("deposit")
162
+ @cli_error_handler
163
+ def deposit(
164
+ amount_wei: Annotated[
165
+ int,
166
+ typer.Argument(help="Amount to deposit, in wei (18-decimal)."),
167
+ ],
168
+ rpc_url: RpcOpt = None,
169
+ contract_address: ContractOpt = None,
170
+ private_key: PrivateKeyOpt = None,
171
+ ) -> None:
172
+ """Deposit tokens into the escrow. Requires a private key."""
173
+ client = _resolve_client(rpc_url, contract_address, private_key)
174
+ tx_hash = client.deposit(int(amount_wei))
175
+ print_json({
176
+ "action": "deposit",
177
+ "amount_wei": int(amount_wei),
178
+ "tx_hash": tx_hash,
179
+ "from": client.address,
180
+ })
181
+
182
+
183
+ @app.command("initiate-withdrawal")
184
+ @cli_error_handler
185
+ def initiate_withdrawal(
186
+ amount_wei: Annotated[
187
+ int,
188
+ typer.Argument(help="Amount to withdraw, in wei."),
189
+ ],
190
+ rpc_url: RpcOpt = None,
191
+ contract_address: ContractOpt = None,
192
+ private_key: PrivateKeyOpt = None,
193
+ ) -> None:
194
+ """Start a withdrawal. Subject to the contract's withdrawal delay."""
195
+ client = _resolve_client(rpc_url, contract_address, private_key)
196
+ tx_hash = client.initiate_withdrawal(int(amount_wei))
197
+ print_json({
198
+ "action": "initiate_withdrawal",
199
+ "amount_wei": int(amount_wei),
200
+ "tx_hash": tx_hash,
201
+ "from": client.address,
202
+ })
203
+
204
+
205
+ @app.command("execute-withdrawal")
206
+ @cli_error_handler
207
+ def execute_withdrawal(
208
+ rpc_url: RpcOpt = None,
209
+ contract_address: ContractOpt = None,
210
+ private_key: PrivateKeyOpt = None,
211
+ ) -> None:
212
+ """Finalise a previously-initiated withdrawal after the delay has elapsed."""
213
+ client = _resolve_client(rpc_url, contract_address, private_key)
214
+ tx_hash = client.execute_withdrawal()
215
+ print_json({
216
+ "action": "execute_withdrawal",
217
+ "tx_hash": tx_hash,
218
+ "from": client.address,
219
+ })
220
+
221
+
222
+ @app.command("cancel-withdrawal")
223
+ @cli_error_handler
224
+ def cancel_withdrawal(
225
+ rpc_url: RpcOpt = None,
226
+ contract_address: ContractOpt = None,
227
+ private_key: PrivateKeyOpt = None,
228
+ ) -> None:
229
+ """Cancel a pending withdrawal request before it unlocks."""
230
+ client = _resolve_client(rpc_url, contract_address, private_key)
231
+ tx_hash = client.cancel_withdrawal()
232
+ print_json({
233
+ "action": "cancel_withdrawal",
234
+ "tx_hash": tx_hash,
235
+ "from": client.address,
236
+ })
@@ -0,0 +1,76 @@
1
+ """``spacerouter receipts`` — on-chain receipt state queries.
2
+
3
+ Consumer-facing view. Given a client address + request UUID, check
4
+ whether the escrow has settled that receipt on-chain. JSON output
5
+ only.
6
+
7
+ For a provider's *local* receipt state (signed vs failed vs locked),
8
+ see the provider CLI at ``python -m app.main --receipts`` on the
9
+ node — that operates against the provider's local SQLite, not the
10
+ chain.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Annotated, Optional
17
+
18
+ import typer
19
+
20
+ from spacerouter_cli.commands.escrow import (
21
+ ContractOpt, RpcOpt, _resolve_client,
22
+ )
23
+ from spacerouter_cli.output import cli_error_handler, print_json
24
+
25
+ app = typer.Typer(
26
+ help=(
27
+ "Query on-chain Leg 2 receipt state. For provider-local "
28
+ "receipt state, run `python -m app.main --receipts` on the node."
29
+ ),
30
+ no_args_is_help=True,
31
+ )
32
+
33
+
34
+ @app.command("is-settled")
35
+ @cli_error_handler
36
+ def is_settled(
37
+ client_address: Annotated[
38
+ str, typer.Argument(help="Receipt client (payer) address."),
39
+ ],
40
+ request_uuid: Annotated[
41
+ str, typer.Argument(help="Receipt UUID (per-client nonce)."),
42
+ ],
43
+ rpc_url: RpcOpt = None,
44
+ contract_address: ContractOpt = None,
45
+ ) -> None:
46
+ """Check whether a specific receipt has been claimed on-chain."""
47
+ client = _resolve_client(rpc_url, contract_address)
48
+ used = client.is_nonce_used(client_address, request_uuid)
49
+ print_json({
50
+ "client_address": client_address,
51
+ "request_uuid": request_uuid,
52
+ "settled_on_chain": used,
53
+ })
54
+
55
+
56
+ @app.command("show")
57
+ @cli_error_handler
58
+ def show(
59
+ client_address: Annotated[
60
+ str, typer.Argument(help="Receipt client (payer) address."),
61
+ ],
62
+ request_uuid: Annotated[
63
+ str, typer.Argument(help="Receipt UUID."),
64
+ ],
65
+ rpc_url: RpcOpt = None,
66
+ contract_address: ContractOpt = None,
67
+ ) -> None:
68
+ """Alias for ``is-settled`` — returns the same on-chain state."""
69
+ client = _resolve_client(rpc_url, contract_address)
70
+ used = client.is_nonce_used(client_address, request_uuid)
71
+ print_json({
72
+ "client_address": client_address,
73
+ "request_uuid": request_uuid,
74
+ "settled_on_chain": used,
75
+ "status": "claimed" if used else "unclaimed_on_chain",
76
+ })
@@ -7,7 +7,10 @@ import json
7
7
  import typer
8
8
 
9
9
  from spacerouter_cli import __version__
10
- from spacerouter_cli.commands import api_key, billing, config_cmd, dashboard, node, request, status
10
+ from spacerouter_cli.commands import (
11
+ api_key, billing, config_cmd, dashboard, escrow, node, receipts,
12
+ request, status,
13
+ )
11
14
 
12
15
  app = typer.Typer(
13
16
  name="spacerouter",
@@ -22,6 +25,8 @@ app.add_typer(node.app, name="node", help="Manage proxy nodes")
22
25
  app.add_typer(billing.app, name="billing", help="Billing and checkout")
23
26
  app.add_typer(dashboard.app, name="dashboard", help="Dashboard data")
24
27
  app.add_typer(config_cmd.app, name="config", help="Configuration management")
28
+ app.add_typer(escrow.app, name="escrow", help="Escrow wallet / deposit / withdrawal")
29
+ app.add_typer(receipts.app, name="receipts", help="On-chain Leg 2 receipt state queries")
25
30
  app.command(name="status", help="Check service health")(status.status)
26
31
 
27
32
 
@@ -0,0 +1,152 @@
1
+ """Tests for ``spacerouter escrow`` and ``spacerouter receipts`` sub-apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import patch, MagicMock
6
+
7
+ import pytest
8
+ from typer.testing import CliRunner
9
+
10
+ from spacerouter_cli.main import app
11
+ from tests.conftest import parse_json_output
12
+
13
+
14
+ @pytest.fixture
15
+ def runner():
16
+ return CliRunner()
17
+
18
+
19
+ @pytest.fixture
20
+ def escrow_env(monkeypatch):
21
+ monkeypatch.setenv("SR_ESCROW_CHAIN_RPC",
22
+ "https://rpc.cc3-testnet.creditcoin.network")
23
+ monkeypatch.setenv("SR_ESCROW_CONTRACT_ADDRESS",
24
+ "0xC5740e4e9175301a24FB6d22bA184b8ec0762852")
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_escrow_client():
29
+ """Patch EscrowClient so no real RPC calls go out."""
30
+ with patch(
31
+ "spacerouter_cli.commands.escrow.EscrowClient"
32
+ ) as cls:
33
+ inst = MagicMock()
34
+ cls.return_value = inst
35
+ yield inst
36
+
37
+
38
+ class TestEscrowBalance:
39
+ def test_balance_emits_wei_and_space(
40
+ self, runner, escrow_env, mock_escrow_client,
41
+ ):
42
+ mock_escrow_client.balance.return_value = 5 * 10**18
43
+ result = runner.invoke(app, [
44
+ "escrow", "balance", "0xabcd" + "0" * 36,
45
+ ])
46
+ assert result.exit_code == 0
47
+ data = parse_json_output(result.output)
48
+ assert data["escrow_balance_wei"] == 5 * 10**18
49
+ assert data["escrow_balance_space"] == 5.0
50
+
51
+ def test_balance_requires_rpc(self, runner, monkeypatch):
52
+ monkeypatch.delenv("SR_ESCROW_CHAIN_RPC", raising=False)
53
+ monkeypatch.delenv("SR_ESCROW_CONTRACT_ADDRESS", raising=False)
54
+ result = runner.invoke(app, [
55
+ "escrow", "balance", "0xabcd" + "0" * 36,
56
+ ])
57
+ assert result.exit_code != 0
58
+
59
+
60
+ class TestWithdrawalRequest:
61
+ def test_pending_withdrawal_fields(
62
+ self, runner, escrow_env, mock_escrow_client,
63
+ ):
64
+ mock_escrow_client.withdrawal_request.return_value = (
65
+ 10**18, 1_800_000_000, True,
66
+ )
67
+ result = runner.invoke(app, [
68
+ "escrow", "withdrawal-request", "0x" + "a" * 40,
69
+ ])
70
+ assert result.exit_code == 0
71
+ data = parse_json_output(result.output)
72
+ assert data["has_pending_withdrawal"] is True
73
+ assert data["amount_wei"] == 10**18
74
+ assert data["amount_space"] == 1.0
75
+ assert data["unlock_at_epoch_seconds"] == 1_800_000_000
76
+
77
+
78
+ class TestWithdrawalDelay:
79
+ def test_surfaces_days(self, runner, escrow_env, mock_escrow_client):
80
+ mock_escrow_client.withdrawal_delay.return_value = 5 * 86400
81
+ result = runner.invoke(app, ["escrow", "withdrawal-delay"])
82
+ assert result.exit_code == 0
83
+ data = parse_json_output(result.output)
84
+ assert data["withdrawal_delay_seconds"] == 432_000
85
+ assert data["withdrawal_delay_days"] == 5.0
86
+
87
+
88
+ class TestDeposit:
89
+ def test_deposit_returns_tx_hash(
90
+ self, runner, escrow_env, mock_escrow_client, monkeypatch,
91
+ ):
92
+ monkeypatch.setenv("SR_ESCROW_PRIVATE_KEY", "0x" + "f" * 64)
93
+ mock_escrow_client.deposit.return_value = "0xabc123"
94
+ mock_escrow_client.address = "0x" + "a" * 40
95
+ result = runner.invoke(app, ["escrow", "deposit", "1000000000000000000"])
96
+ assert result.exit_code == 0
97
+ data = parse_json_output(result.output)
98
+ assert data["tx_hash"] == "0xabc123"
99
+ assert data["action"] == "deposit"
100
+ mock_escrow_client.deposit.assert_called_once_with(10**18)
101
+
102
+
103
+ class TestReceiptsIsSettled:
104
+ def test_returns_on_chain_state(
105
+ self, runner, escrow_env, mock_escrow_client,
106
+ ):
107
+ mock_escrow_client.is_nonce_used.return_value = True
108
+ result = runner.invoke(app, [
109
+ "receipts", "is-settled",
110
+ "0x" + "a" * 40,
111
+ "9f8e5c21-1234-4567-89ab-cdef01234567",
112
+ ])
113
+ assert result.exit_code == 0
114
+ data = parse_json_output(result.output)
115
+ assert data["settled_on_chain"] is True
116
+ assert data["request_uuid"] == "9f8e5c21-1234-4567-89ab-cdef01234567"
117
+
118
+ def test_unsettled_returns_false(
119
+ self, runner, escrow_env, mock_escrow_client,
120
+ ):
121
+ mock_escrow_client.is_nonce_used.return_value = False
122
+ result = runner.invoke(app, [
123
+ "receipts", "is-settled",
124
+ "0x" + "a" * 40,
125
+ "11111111-2222-3333-4444-555555555555",
126
+ ])
127
+ assert result.exit_code == 0
128
+ data = parse_json_output(result.output)
129
+ assert data["settled_on_chain"] is False
130
+
131
+
132
+ class TestReceiptsShow:
133
+ def test_emits_status_field(
134
+ self, runner, escrow_env, mock_escrow_client,
135
+ ):
136
+ mock_escrow_client.is_nonce_used.return_value = False
137
+ result = runner.invoke(app, [
138
+ "receipts", "show",
139
+ "0x" + "b" * 40,
140
+ "22222222-3333-4444-5555-666666666666",
141
+ ])
142
+ assert result.exit_code == 0
143
+ data = parse_json_output(result.output)
144
+ assert data["status"] == "unclaimed_on_chain"
145
+
146
+
147
+ class TestSubAppsRegistered:
148
+ def test_escrow_appears_in_help(self, runner):
149
+ result = runner.invoke(app, ["--help"])
150
+ assert result.exit_code == 0
151
+ assert "escrow" in result.output
152
+ assert "receipts" in result.output