Skip to content

[Bug] alw claim signs claim_slash with the hotkey, but the contract gates it on caller == swap.user (the user's coldkey / receive address) — so a pending slash can't be claimed from the wallet that created the swap #471

@JSONbored

Description

@JSONbored

Summary

When a swap times out, the contract slashes the miner and pays the user directly; if that direct transfer fails, the funds are parked in pending_slashes for the user to retrieve via claim_slash (lib.rs:1029-1043). The contract's own doc comment states the intent — "Claim a pending slash payout (user calls after failed transfer)" (lib.rs:1189) — and enforces it with caller == swap.user (lib.rs:1195).

But alw claim submits claim_slash signed with wallet.hotkey (contract_client.py:1286_exec_logged:1020 keypair=wallet.hotkey), while swap.user is the user's TAO address — the coldkey for a TAO→BTC swap (swap.py:948) or the receive address for a BTC→TAO swap (swap.py:933 / axon_handlers.py:733-738). A wallet's hotkey is neither. So for every recommended configuration user != caller holds and the claim returns Error::InvalidStatus (lib.rs:1196).

Result: a user running alw claim from the very wallet that created the swap is rejected by the contract's own access check, and the only shipped tool for the documented slash-recovery path cannot execute it. This is the same hotkey-vs-coldkey signing-identity mismatch already guarded on the TAO-send path (#270) and the collateral path (#380) — here on the slash-claim path.

Affected code (branch test, e360977; identical on main, db9ab0d)

The CLI signs with the hotkeyallways/contract_client.py:1286_exec_logged (:1011-1022):

def claim_slash(self, wallet: bt.Wallet, swap_id: int) -> str:
    return self._exec_logged('claim_slash', wallet, f'Slash claimed for swap {swap_id}', {'swap_id': swap_id})
# _exec_logged:
tx_hash = self.exec_contract_raw(method, args=args, keypair=wallet.hotkey, value=value)   # :1020

Every contract_client write signs with wallet.hotkey (grep keypair=wallet.coldkey → 0 matches); there is no coldkey-signed path.

The contract requires caller == swap.usersmart-contracts/ink/lib.rs:1189-1197:

/// Claim a pending slash payout (user calls after failed transfer)
pub fn claim_slash(&mut self, swap_id: u64) -> Result<(), Error> {
    let caller = self.env().caller();
    let (user, amount) = self.pending_slashes.get(swap_id).ok_or(Error::NoPendingSlash)?;
    if user != caller {
        return Err(Error::InvalidStatus);
    }
    ...

pending_slashes is keyed to swap.user, populated only when the direct timeout payout fails — lib.rs:1029-1038:

let actual_slash = this.apply_collateral_penalty(swap.miner, swap.tao_amount);
if actual_slash > 0 {
    if this.env().transfer(swap.user, actual_slash).is_ok() {
        ...                                                          // direct payout to swap.user
    } else {
        this.pending_slashes.insert(swap_id, &(swap.user, actual_slash));   // <-- claim path

swap.user is the user's TAO address, never the hotkeyallways/validator/axon_handlers.py:733-738 derives it (to_address for BTC→TAO, from_address for TAO→BTC) and stores it on-chain as swap.user; the CLI sets those addresses in allways/cli/swap_commands/swap.py:

  • TAO→BTC source (:948): user_from_address = wallet.coldkeypub.ss58_addressswap.user = the coldkey.
  • BTC→TAO receive (:933): click.prompt('Your TAO receive address')swap.user = the user-supplied receive address.

The display reinforces the wrong identityallways/cli/swap_commands/claim.py:65-66:

console.print(f'  Claiming:   {wallet.hotkey.ss58_address}')
console.print('[dim]  Only the original swap user can claim; others will be rejected on-chain.[/dim]')

It prints the hotkey as the claimant and asserts the user can claim — then signs with the hotkey, which is not swap.user.

Why it fails (proof)

  • caller = the extrinsic signer = wallet.hotkey (the only keypair contract_client ever signs with).
  • swap.user ∈ { user coldkey (TAO→BTC), user receive address (BTC→TAO) }.
  • A wallet's hotkey and coldkey are distinct accounts, and a separately-supplied receive address is a third — so user != caller and the claim is rejected with InvalidStatus deterministically.

Honest scope (the one exception): the claim succeeds only if swap.user happens to equal wallet.hotkey.ss58_address — i.e. a BTC→TAO user who typed their own hotkey as the receive address. That configuration is explicitly discouraged by the existing guard in #270 (because TAO transfers sign from the coldkey, not the hotkey), so the claim is broken precisely for the recommended setups — the self-custody coldkey source and any distinct receive address.

Reachability / severity

pending_slashes is populated only when the direct timeout transfer to swap.user fails (lib.rs:1031 else). The realistic trigger is a BTC→TAO swap whose swap.user is a fresh TAO receive address below the existential deposit, so env().transfer cannot create it and the payout falls through to pending_slashes — which is exactly the make-whole path that exists for "the user's payout couldn't be delivered." When any pending slash exists, alw claim is the only shipped recovery tool, and it is signed with the wrong key. So this is not cosmetic: the documented recovery path is unusable from shipped tooling for the recommended configurations, with the user's make-whole funds (up to the slashed tao_amount) left in pending_slashes. (For the TAO→BTC case the direct payout to the funded coldkey usually succeeds, so a pending slash is rarer there — but when one exists, the fix below makes it claimable.)

Relationship to prior work (distinct, and the precedents point the same way)

Exhaustive dedup (issues + PRs, all states, GraphQL): no issue or PR addresses the claim_slash signing identity. The claim_slash/pending_slashes hits are #27 (above) and _exec_logged refactors (#91/#94/#104/#89); none change which key signs the claim.

Suggested fix

claim_slash is a user action; its on-chain caller must equal swap.user:

  1. Sign the claim with the key that matches swap.user. For the self-custody TAO→BTC case (swap.user = coldkey), sign claim_slash with wallet.coldkey (a coldkey-signed path for this user-facing call), consistent with the "TAO actions sign from the coldkey" model behind cli: warn when committing hotkey as TAO send address #270/Add alw collateral recover-from-hotkey command #380.
  2. Pre-flight the BTC→TAO case. Read the swap, derive swap.user; if it matches a key the wallet controls, sign with it; otherwise refuse with a clear message — "this slash is claimable only from <swap.user>; submit the claim from that account" — instead of broadcasting a guaranteed-to-fail hotkey transaction.
  3. Fix claim.py:65 to display swap.user (the required claimant), not wallet.hotkey.

Suggested test (tests/)

  • TAO→BTC: a swap whose swap.user is the wallet coldkey ss58 with a populated pending_slashes; assert alw claim signs claim_slash with the coldkey (caller == swap.user) and the contract accepts.
  • BTC→TAO with an address the wallet does not control: assert the command refuses pre-flight ("claim from <addr>") and does not broadcast.
  • Regression guard: assert claim_slash is never signed with wallet.hotkey.

Verified against test @ e360977 and main @ db9ab0d (identical). claim_slash has signed with wallet.hotkey since the initial commit; the contract gate caller == swap.user (lib.rs:1195) and pending_slashes keying (:1038) are current. PR #163 (merged) changed only this command's messaging; PR #27 (closed) targeted the reaped-account transfer inside claim_slash, not the client signing identity; PR #270 / #380 (merged) fix the same hotkey-vs-coldkey signing mismatch on other paths, client-side. GraphQL dedup across all issues/PRs shows nothing touching the claim signing key. Fix is Python-only (claim.py + a coldkey-signed claim_slash path).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions