You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[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
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 hotkey — allways/contract_client.py:1286 → _exec_logged (:1011-1022):
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.user — smart-contracts/ink/lib.rs:1189-1197:
/// Claim a pending slash payout (user calls after failed transfer)pubfn claim_slash(&mutself,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 {returnErr(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 hotkey — allways/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:
BTC→TAO receive (:933): click.prompt('Your TAO receive address') → swap.user = the user-supplied receive address.
The display reinforces the wrong identity — allways/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:1031else). 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)
PR Fix permanent fund lock in pending slashes #27 ("Fix permanent fund lock in pending slashes", closed) targeted a different failure point: a reaped user account makes the env().transfer(caller, amount)insideclaim_slash (lib.rs:1200) fail — i.e. it passes the caller == user gate, then the transfer fails. It was closed on the argument that the lock isn't permanent because "the user holds the key" and can re-fund their account and claim. That rebuttal assumes the user can call claim_slash from their own (swap.user) account — which the shipped alw claim does not allow, because it signs with the hotkey. This issue is that earlier, orthogonal gate (:1195); it is the precondition Fix permanent fund lock in pending slashes #27's resolution relies on, and its fix is client-side (no contract change, sidestepping Fix permanent fund lock in pending slashes #27's owner-privilege objection).
PR alw claim: clearer messaging for every caller #163 (merged) reworked this command's messaging ("only the original user can claim") without touching the signing key — it added the copy that masks this mismatch.
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:
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.
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 insideclaim_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).
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_slashesfor the user to retrieve viaclaim_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 withcaller == swap.user(lib.rs:1195).But
alw claimsubmitsclaim_slashsigned withwallet.hotkey(contract_client.py:1286→_exec_logged→:1020 keypair=wallet.hotkey), whileswap.useris 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 configurationuser != callerholds and the claim returnsError::InvalidStatus(lib.rs:1196).Result: a user running
alw claimfrom 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 onmain,db9ab0d)The CLI signs with the hotkey —
allways/contract_client.py:1286→_exec_logged(:1011-1022):Every
contract_clientwrite signs withwallet.hotkey(grep keypair=wallet.coldkey→ 0 matches); there is no coldkey-signed path.The contract requires
caller == swap.user—smart-contracts/ink/lib.rs:1189-1197:pending_slashesis keyed toswap.user, populated only when the direct timeout payout fails —lib.rs:1029-1038:swap.useris the user's TAO address, never the hotkey —allways/validator/axon_handlers.py:733-738derives it (to_addressfor BTC→TAO,from_addressfor TAO→BTC) and stores it on-chain asswap.user; the CLI sets those addresses inallways/cli/swap_commands/swap.py::948):user_from_address = wallet.coldkeypub.ss58_address→swap.user= the coldkey.:933):click.prompt('Your TAO receive address')→swap.user= the user-supplied receive address.The display reinforces the wrong identity —
allways/cli/swap_commands/claim.py:65-66: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 keypaircontract_clientever signs with).swap.user∈ { user coldkey (TAO→BTC), user receive address (BTC→TAO) }.user != callerand the claim is rejected withInvalidStatusdeterministically.Honest scope (the one exception): the claim succeeds only if
swap.userhappens to equalwallet.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_slashesis populated only when the direct timeout transfer toswap.userfails (lib.rs:1031else). The realistic trigger is a BTC→TAO swap whoseswap.useris a fresh TAO receive address below the existential deposit, soenv().transfercannot create it and the payout falls through topending_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 claimis 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 slashedtao_amount) left inpending_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)
env().transfer(caller, amount)insideclaim_slash(lib.rs:1200) fail — i.e. it passes thecaller == usergate, then the transfer fails. It was closed on the argument that the lock isn't permanent because "the user holds the key" and can re-fund their account and claim. That rebuttal assumes the user can callclaim_slashfrom their own (swap.user) account — which the shippedalw claimdoes not allow, because it signs with the hotkey. This issue is that earlier, orthogonal gate (:1195); it is the precondition Fix permanent fund lock in pending slashes #27's resolution relies on, and its fix is client-side (no contract change, sidestepping Fix permanent fund lock in pending slashes #27's owner-privilege objection).Exhaustive dedup (issues + PRs, all states, GraphQL): no issue or PR addresses the
claim_slashsigning identity. Theclaim_slash/pending_slasheshits are #27 (above) and_exec_loggedrefactors (#91/#94/#104/#89); none change which key signs the claim.Suggested fix
claim_slashis a user action; its on-chain caller must equalswap.user:swap.user. For the self-custody TAO→BTC case (swap.user= coldkey), signclaim_slashwithwallet.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.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.claim.py:65to displayswap.user(the required claimant), notwallet.hotkey.Suggested test (
tests/)swap.useris the wallet coldkey ss58 with a populatedpending_slashes; assertalw claimsignsclaim_slashwith the coldkey (caller == swap.user) and the contract accepts.<addr>") and does not broadcast.claim_slashis never signed withwallet.hotkey.Verified against
test@e360977andmain@db9ab0d(identical).claim_slashhas signed withwallet.hotkeysince the initial commit; the contract gatecaller == swap.user(lib.rs:1195) andpending_slasheskeying (:1038) are current. PR #163 (merged) changed only this command's messaging; PR #27 (closed) targeted the reaped-account transfer insideclaim_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-signedclaim_slashpath).