Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ alw miner post sol <addr> btc <addr> <rate> # 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 <TAO> # bond into the vault (signed by the hotkey)
alw vault deposit --amount <TAO> # 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
Expand Down
12 changes: 12 additions & 0 deletions allways/cli/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {}
Expand Down
5 changes: 3 additions & 2 deletions allways/cli/swap_commands/collateral.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down
27 changes: 23 additions & 4 deletions allways/cli/swap_commands/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import math
import os
import re
import sys
import time
from dataclasses import dataclass, field
Expand All @@ -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

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -877,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',
]
Expand All @@ -888,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}`).'
46 changes: 30 additions & 16 deletions allways/cli/swap_commands/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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)
Expand All @@ -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 <hotkey>` 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)}')
Expand Down
38 changes: 22 additions & 16 deletions allways/solana/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

import base64
import json
import re
import time
from dataclasses import dataclass
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
6 changes: 3 additions & 3 deletions smart-contracts/ink-bond-vault/e2e_lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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; }

Expand Down
20 changes: 20 additions & 0 deletions tests/test_cli_miner_solana.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions tests/test_cli_program_errors.py
Original file line number Diff line number Diff line change
@@ -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'
)
8 changes: 8 additions & 0 deletions tests/test_cli_vault_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,)
Loading