From 36676d239c3f663464e4dec28a24923f6b9bba66 Mon Sep 17 00:00:00 2001 From: Landyn Date: Wed, 2 Sep 2026 18:27:50 -0500 Subject: [PATCH 1/2] Surface Solana program rejections as one actionable line in every alw command --- allways/cli/help.py | 12 ++++++++ allways/cli/swap_commands/helpers.py | 21 +++++++++++++- allways/solana/client.py | 38 ++++++++++++++----------- tests/test_cli_miner_solana.py | 20 +++++++++++++ tests/test_cli_program_errors.py | 42 ++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 tests/test_cli_program_errors.py diff --git a/allways/cli/help.py b/allways/cli/help.py index cf9373d6..740e4447 100644 --- a/allways/cli/help.py +++ b/allways/cli/help.py @@ -12,6 +12,9 @@ from rich.panel import Panel from rich.table import Table +from allways.solana.client import SolanaClientError +from allways.solana.rpc import SolanaRpcError + DISCLAIMER = ( 'Allways is permissionless, open-source, beta software. Swaps settle directly between' ' counterparty wallets; the protocol never takes custody of user funds, and the protocol fee is' @@ -183,6 +186,15 @@ def __init__(self, *args, show_disclaimer: bool = False, **kwargs): super().__init__(*args, **kwargs) self.show_disclaimer = show_disclaimer + def invoke(self, ctx: click.Context): + # Single choke point: no RPC dict or traceback reaches the operator from any subcommand. + try: + return super().invoke(ctx) + except (SolanaClientError, SolanaRpcError) as e: + from allways.cli.swap_commands.helpers import fail, solana_failure_message # package imports this module + + fail(solana_failure_message(e)) + def alias_map(self) -> dict[str, list[str]]: """Return canonical-command -> aliases mapping.""" return {} diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index 0d01666d..adb90556 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -1,6 +1,7 @@ import json import math import os +import re import sys import time from dataclasses import dataclass, field @@ -20,7 +21,7 @@ from allways.cli.swap_commands.swap_intake import backing_purse, floors_from_config from allways.constants import NETUID_FINNEY, TAO_TO_RAO, declarable_backings from allways.solana import pdas -from allways.solana.client import SolanaClientError +from allways.solana.client import PROGRAM_ERRORS, SolanaClientError, program_error_code from allways.solana.layouts import hub_busy_until, hub_swap_on, lock_max from allways.solana.rpc import SolanaRpcError, SolanaRpcUnreachable, resolve_rpc_url @@ -267,6 +268,24 @@ def live_unclaimed(resv) -> bool: return int(resv.reserved_until) > now and bytes(resv.claimed_swap_key) == EMPTY_SWAP_KEY +# Operator next step per Anchor code where the IDL message alone is not actionable from the CLI. +PROGRAM_ERROR_HINTS = { + 6002: 'Deposit collateral first: alw collateral deposit', + 6003: 'Deactivate before withdrawing: alw miner deactivate', + 6015: 'Activate first: alw miner activate', +} + + +def solana_failure_message(err: Exception) -> str: + """One actionable line for a program/RPC failure — never the raw RPC dict or a traceback.""" + code = program_error_code(err) + if code is not None: + name, msg = PROGRAM_ERRORS.get(code, ('UnknownError', 'Program rejected the transaction')) + return f'{PROGRAM_ERROR_HINTS.get(code, msg)} ({name} {code})' + rpc_message = re.search(r"'message': '([^']*)'", str(err)) + return rpc_message.group(1) if rpc_message else str(err) + + def print_json(data) -> None: """Emit a value as pretty JSON (str fallback for non-serializable types like Pubkey).""" click.echo(json.dumps(data, indent=2, default=str)) diff --git a/allways/solana/client.py b/allways/solana/client.py index 46ba8ab1..f380c1f7 100644 --- a/allways/solana/client.py +++ b/allways/solana/client.py @@ -7,6 +7,7 @@ """ import base64 +import json import re import time from dataclasses import dataclass @@ -20,6 +21,7 @@ from solders.transaction import Transaction from allways.constants import VOTE_ROUND_TTL_SECS +from allways.metadata import METADATA_DIR from allways.solana import layouts, pdas from allways.solana.program import resolve_program_id from allways.solana.rpc import SolanaRpc @@ -152,22 +154,26 @@ def contract_reject_reason(err: Exception) -> Optional[str]: return 'miner is not available for reservation right now' -# Contract ErrorCode names → Anchor codes (6000 + enum index). A landed failed tx stringifies as -# {'Custom': N} with no name (see contract_reject_reason), so benign classification matches both forms. -_ERROR_CODES = { - 'NotValidator': 6007, - 'AlreadyVoted': 6012, - 'NotPending': 6031, - 'ClaimNotExpired': 6033, - 'ExtensionNotLater': 6034, - 'ExtensionExceedsCeiling': 6035, - 'PoolNotClosed': 6042, - 'NoRequests': 6044, - 'SeedSlotNotYetProduced': 6045, - 'AlreadyFilled': 6046, - 'WeightsUpdateTooSoon': 6050, - 'AttestationWouldStrandSwap': 6067, -} +def _load_program_errors() -> dict: + idl = json.loads((METADATA_DIR / 'allways_swap_manager.json').read_text()) + return {e['code']: (e['name'], e['msg']) for e in idl['errors']} + + +# Anchor code → (ErrorCode name, message), straight from the packaged IDL — the one source of truth. +PROGRAM_ERRORS = _load_program_errors() +_ERROR_CODES = {name: code for code, (name, _) in PROGRAM_ERRORS.items()} + + +def program_error_code(err: Exception) -> Optional[int]: + """The Anchor error code inside an RPC/confirm failure, or None when the failure is not a program + rejection. A pre-flight reject carries `Error Number: N` / `custom program error: 0xHEX`; a landed + failed tx surfaces as {'Custom': N} (see contract_reject_reason).""" + s = str(err) + m = re.search(r"'Custom':\s*(\d+)", s) or re.search(r'Error Number:\s*(\d+)', s) + if m: + return int(m.group(1)) + m = re.search(r'custom program error:\s*0x([0-9a-fA-F]+)', s) + return int(m.group(1), 16) if m else None def benign_marker(err: Exception, names: Tuple[str, ...]) -> Optional[str]: diff --git a/tests/test_cli_miner_solana.py b/tests/test_cli_miner_solana.py index 0c211004..2f531763 100644 --- a/tests/test_cli_miner_solana.py +++ b/tests/test_cli_miner_solana.py @@ -16,6 +16,7 @@ from allways.cli.swap_commands.miner_commands import miner_group from allways.cli.swap_commands.pair import post_pair from allways.solana.pdas import BACKING_BIT_SOL, BACKING_BIT_TAO +from allways.solana.rpc import SolanaRpcError def _config(**over): @@ -166,6 +167,25 @@ def test_bind_hotkey_signs_and_binds(): assert bt.Keypair(public_key='0x' + hotkey_bytes.hex()).verify(bytes(c.keypair.pubkey()), sig) +def test_bind_hotkey_without_collateral_is_one_actionable_line(): + wallet = SimpleNamespace(hotkey=bt.Keypair.create_from_seed('0x' + '33' * 32)) + c = _client() + c.get_binding.return_value = None + c.bind_hotkey.side_effect = SolanaRpcError( + "sendTransaction: {'code': -32002, 'message': 'Transaction simulation failed: Error processing " + "Instruction 0: custom program error: 0x1772', 'data': {'err': {'InstructionError': [0, {'Custom': 6002}]}, " + "'logs': ['Program log: AnchorError occurred. Error Code: InsufficientCollateral. Error Number: 6002.']}}" + ) + with ( + patch('allways.cli.swap_commands.bind.get_cli_context', return_value=({}, wallet, None, None)), + patch('allways.cli.swap_commands.bind.get_solana_cli_context', return_value=({}, c)), + ): + res = CliRunner().invoke(miner_group, ['bind-hotkey', '--yes']) + assert res.exit_code == 1 + assert 'Deposit collateral first: alw collateral deposit (InsufficientCollateral 6002)' in res.output + assert 'Traceback' not in res.output and "'code'" not in res.output + + def test_bind_hotkey_skips_when_already_bound(): wallet = SimpleNamespace(hotkey=bt.Keypair.create_from_seed('0x' + '22' * 32)) c = _client() diff --git a/tests/test_cli_program_errors.py b/tests/test_cli_program_errors.py new file mode 100644 index 00000000..a0f045be --- /dev/null +++ b/tests/test_cli_program_errors.py @@ -0,0 +1,42 @@ +"""Program/RPC failures reach the operator as one actionable line — never a traceback or the raw RPC dict.""" + +from allways.cli.swap_commands.helpers import solana_failure_message +from allways.solana.client import PROGRAM_ERRORS, program_error_code +from allways.solana.rpc import SolanaRpcError + +PREFLIGHT = ( + "sendTransaction: {'code': -32002, 'message': 'Transaction simulation failed: Error processing Instruction 0: " + "custom program error: 0x1772', 'data': {'err': {'InstructionError': [0, {'Custom': 6002}]}}}" +) +LANDED = "tx SIG failed: {'InstructionError': [0, {'Custom': 6015}]}" + + +def test_program_error_code_reads_preflight_landed_and_hex_forms(): + assert program_error_code(Exception(PREFLIGHT)) == 6002 + assert program_error_code(Exception(LANDED)) == 6015 + assert program_error_code(Exception('custom program error: 0x1772')) == 6002 + assert program_error_code(Exception("tx SIG failed: {'InstructionError': [0, 'InvalidAccountData']}")) is None + + +def test_program_errors_come_from_the_packaged_idl(): + assert PROGRAM_ERRORS[6002] == ('InsufficientCollateral', 'Insufficient collateral for this withdrawal') + + +def test_failure_message_hints_or_falls_back_to_idl_message(): + assert solana_failure_message(SolanaRpcError(PREFLIGHT)) == ( + 'Deposit collateral first: alw collateral deposit (InsufficientCollateral 6002)' + ) + assert solana_failure_message(SolanaRpcError("tx SIG failed: {'InstructionError': [0, {'Custom': 6018}]}")) == ( + 'System is halted (SystemHalted 6018)' + ) + + +def test_failure_message_keeps_only_the_rpc_message_line(): + err = SolanaRpcError( + "sendTransaction: {'code': -32002, 'message': 'Transaction simulation failed: Blockhash not found', 'data': {'logs': []}}" + ) + assert solana_failure_message(err) == 'Transaction simulation failed: Blockhash not found' + assert ( + solana_failure_message(SolanaRpcError('tx SIG not confirmed within 30.0s')) + == 'tx SIG not confirmed within 30.0s' + ) From 317741d08839fd0da0ff943176683299a92af649 Mon Sep 17 00:00:00 2001 From: Landyn Date: Wed, 2 Sep 2026 18:30:18 -0500 Subject: [PATCH 2/2] Rename alw vault post-collateral to deposit --amount, twin of alw collateral deposit --- README.md | 4 +- allways/cli/swap_commands/collateral.py | 5 ++- allways/cli/swap_commands/helpers.py | 6 +-- allways/cli/swap_commands/vault.py | 46 ++++++++++++++-------- smart-contracts/ink-bond-vault/e2e_lock.sh | 6 +-- tests/test_cli_vault_admin.py | 8 ++++ 6 files changed, 49 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9451d8cb..40fd2ed4 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ alw miner post sol btc # quote so activation waits on validators mirroring it to Solana rather than on a local read: ```bash -alw collateral deposit 0.1 # one-time identity deposit — see the note below +alw collateral deposit --amount 0.1 # one-time identity deposit — see the note below alw miner bind-hotkey # the vault keys bonds by hotkey, joined via this binding -alw vault post-collateral # bond into the vault (signed by the hotkey) +alw vault deposit --amount # bond into the vault (signed by the hotkey) alw vault lock # enter service — only a LOCKED bond is attested # wait a minute: validators mirror the bond to Solana alw miner activate --backing tao # validators vote that purse active diff --git a/allways/cli/swap_commands/collateral.py b/allways/cli/swap_commands/collateral.py index 10329cea..254c129b 100644 --- a/allways/cli/swap_commands/collateral.py +++ b/allways/cli/swap_commands/collateral.py @@ -36,7 +36,7 @@ @click.group('collateral', cls=StyledGroup, show_disclaimer=True) def collateral_group(): - """Manage miner collateral.""" + """Manage SOL collateral on Solana (the TAO bond lives under `alw vault`).""" pass @@ -46,7 +46,8 @@ def collateral_group(): def collateral_deposit(amount: float | None, yes: bool): """Deposit SOL collateral to the swap program. - [dim]Amount is in SOL, posted from your Solana keypair (SOLANA_KEYPAIR_PATH / solana-keypair config / ~/.solana/id.json).[/dim] + [dim]Amount is in SOL, posted from your Solana keypair (SOLANA_KEYPAIR_PATH / solana-keypair config / ~/.solana/id.json). + The SOL twin of `alw vault deposit` (TAO).[/dim] [dim]Examples: $ alw collateral deposit --amount 5.0 diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index adb90556..46878232 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -896,7 +896,7 @@ def activation_prerequisites(backing: str) -> List[str]: if backing == pdas.BACKING_CHAIN_SOL: return ['Collateral posted (alw collateral deposit) — activation gates on the purse, not on quotes'] return [ - f'{backing.upper()} bond posted AND locked in the vault (alw vault post-collateral, alw vault lock)', + f'{backing.upper()} bond posted AND locked in the vault (alw vault deposit, alw vault lock)', 'Validators have mirrored that bond to Solana — the attestation is written on their cadence,' ' so a fresh lock needs a minute', ] @@ -907,7 +907,7 @@ def _underfunded(state: PurseState) -> str: if state.purse is None: return ( f'Your {state.backing.upper()} purse has no LOCKED bond attested on Solana yet ' - f'(`alw vault post-collateral` then `alw vault lock`, then give validators a minute).' + f'(`alw vault deposit` then `alw vault lock`, then give validators a minute).' ) - fix = 'alw collateral deposit' if state.backing == pdas.BACKING_CHAIN_SOL else 'alw vault post-collateral' + fix = 'alw collateral deposit' if state.backing == pdas.BACKING_CHAIN_SOL else 'alw vault deposit' return f'Your {state.backing.upper()} purse holds {state.purse} < the {state.floor} floor (`{fix}`).' diff --git a/allways/cli/swap_commands/vault.py b/allways/cli/swap_commands/vault.py index 0ab00c0e..89d05be8 100644 --- a/allways/cli/swap_commands/vault.py +++ b/allways/cli/swap_commands/vault.py @@ -17,8 +17,8 @@ import click -from allways.cli.help import StyledGroup -from allways.cli.swap_commands.helpers import console, fail, get_cli_context, loading +from allways.cli.help import StyledAliasGroup, StyledGroup +from allways.cli.swap_commands.helpers import FINITE_FLOAT, console, fail, get_cli_context, loading from allways.constants import TAO_HUB_VAULT_ADDRESSES, TAO_TO_RAO from allways.vault import BondVaultClient, VaultConfigError, codec @@ -114,7 +114,7 @@ def _rao_to_tao_flag(rao: int) -> str: # ─── Command group ─────────────────────────────────────────────────────────── -@click.group('vault', cls=StyledGroup, show_disclaimer=True) +@click.group('vault', cls=StyledAliasGroup, show_disclaimer=True) def vault_group(): """TAO bond vault (Bittensor-side collateral for TAO-hub pairs). @@ -205,17 +205,29 @@ def vault_status(miner): console.print() -@vault_group.command('post-collateral', show_disclaimer=True) -@click.argument('amount_tao', type=float) -def vault_post_collateral(amount_tao): - """Post AMOUNT_TAO into the vault as bond (signed by the wallet hotkey).""" - vault = _client() - rao = int(amount_tao * TAO_TO_RAO) +@vault_group.command('deposit', show_disclaimer=True) +@click.option('--amount', default=None, type=FINITE_FLOAT, help='Amount in TAO') +def vault_deposit(amount: float | None): + """Deposit TAO collateral into the bond vault (signed by the wallet hotkey). + + [dim]The TAO twin of `alw collateral deposit` (SOL). Then `alw vault lock` to enter service.[/dim] + + [dim]Examples: + $ alw vault deposit --amount 5.0 + $ alw vault deposit (prompts interactively)[/dim] + """ + if amount is None: + amount = click.prompt('Amount to deposit (TAO)', type=FINITE_FLOAT) + rao = int(amount * TAO_TO_RAO) if rao <= 0: fail('Amount must be > 0') - with loading(f'Posting {_fmt_tao(rao)} to the vault...'): + vault = _client() + with loading(f'Depositing {_fmt_tao(rao)} into the vault...'): result = vault.post_collateral(rao) - _report(result, f'Posted {_fmt_tao(rao)}') + _report(result, f'Deposited {_fmt_tao(rao)}') + + +vault_group.add_alias('deposit', 'post-collateral') @vault_group.command('lock', show_disclaimer=True) @@ -232,18 +244,20 @@ def vault_lock(): @vault_group.command('withdraw', show_disclaimer=True) -@click.argument('amount_tao', type=float) -def vault_withdraw(amount_tao): - """Withdraw AMOUNT_TAO from an UNLOCKED bond back to the hotkey. +@click.option('--amount', default=None, type=FINITE_FLOAT, help='Amount in TAO') +def vault_withdraw(amount: float | None): + """Withdraw TAO collateral from an UNLOCKED bond back to the hotkey. [dim]The exit residual fee settle runs BEFORE validators unlock you, so once you are unlocked the vault balance is exact and fully withdrawable. If the call is refused, re-check `alw vault status ` for the post-settle figure.[/dim] """ - vault = _client() - rao = int(amount_tao * TAO_TO_RAO) + if amount is None: + amount = click.prompt('Amount to withdraw (TAO)', type=FINITE_FLOAT) + rao = int(amount * TAO_TO_RAO) if rao <= 0: fail('Amount must be > 0') + vault = _client() with loading(f'Withdrawing {_fmt_tao(rao)}...'): result = vault.withdraw_collateral(rao) _report(result, f'Withdrew {_fmt_tao(rao)}') diff --git a/smart-contracts/ink-bond-vault/e2e_lock.sh b/smart-contracts/ink-bond-vault/e2e_lock.sh index c7fa968c..86871b68 100755 --- a/smart-contracts/ink-bond-vault/e2e_lock.sh +++ b/smart-contracts/ink-bond-vault/e2e_lock.sh @@ -83,11 +83,11 @@ export ALLWAYS_VAULT_SURI="$SURI" export ALLWAYS_VAULT_METADATA="$(pwd)/target/ink/allways_bond_vault.json" echo "== [3/9] CLI: post_collateral ${POST_TAO}τ + lock_bond" -$ALW vault post-collateral "$POST_TAO" +$ALW vault deposit --amount "$POST_TAO" $ALW vault lock echo "== [4/9] withdraw while LOCKED — must be refused" -if $ALW vault withdraw 0.001 2>&1 | tee "$OUT_DIR/locked_withdraw.log" \ +if $ALW vault withdraw --amount 0.001 2>&1 | tee "$OUT_DIR/locked_withdraw.log" \ | grep -q "ExtrinsicSuccess.*CollateralWithdrawn"; then echo "FAIL: withdraw succeeded on a locked bond"; exit 1 fi @@ -103,7 +103,7 @@ call vote_collect_fees_batch --args "[($DEPLOYER_SS58, $FEE_TOTAL)]" echo "== [7/9] vote_unlock(self, epoch 1) → CLI: withdraw EXACT remainder ${REMAINDER_TAO}τ" call vote_unlock --args "$DEPLOYER_SS58" 1 -$ALW vault withdraw "$REMAINDER_TAO" | tee "$OUT_DIR/final_withdraw.log" +$ALW vault withdraw --amount "$REMAINDER_TAO" | tee "$OUT_DIR/final_withdraw.log" grep -q "Withdrew" "$OUT_DIR/final_withdraw.log" \ || { echo "FAIL: exact-remainder withdraw did not succeed — books don't balance"; exit 1; } diff --git a/tests/test_cli_vault_admin.py b/tests/test_cli_vault_admin.py index ae18231d..21dc800c 100644 --- a/tests/test_cli_vault_admin.py +++ b/tests/test_cli_vault_admin.py @@ -164,3 +164,11 @@ def test_failure_report_keeps_the_event_dump(capsys): vault_cli._report(result, 'unused') out = capsys.readouterr().out assert 'events: System.ExtrinsicFailed' in out + + +def test_vault_deposit_takes_amount_flag_and_keeps_post_collateral_alias(monkeypatch): + client = _client(monkeypatch, post_collateral=VaultCallResult(ok=True, extrinsic_hash='0xabc')) + for cmd in ('deposit', 'post-collateral'): + res = CliRunner().invoke(vault_cli.vault_group, [cmd, '--amount', '1.5']) + assert res.exit_code == 0, res.output + assert client.post_collateral.call_args.args == (1_500_000_000,)