Skip to content

Unstake can be signed and submitted from an owner wallet that cannot pay the fee #343

Description

@jorgecuesta

Unstake can be signed and submitted from an owner wallet that cannot pay the fee

What happens

The unstake flow never checks that the signing owner address can cover the transaction fee. The user picks their suppliers, signs, the modal reports the unstake as completed — and seconds later the chain rejects the transaction at the ante handler for insufficient funds. The suppliers stay staked, and unless the user happens to open the transactions list, nothing tells them.

It happened on mainnet today. Transaction 397, an unstake, 2026-08-20 15:20:35 UTC:

"unstake transaction requested"  estimatedFee=98305
"unstake transaction created"    transactionId=397

Five seconds later, code 5, and the raw log from the chain:

spendable balance 0upokt is smaller than 98305upokt: insufficient funds: insufficient funds
    github.com/cosmos/cosmos-sdk/x/auth/ante.DeductFees
    github.com/cosmos/cosmos-sdk/x/auth/ante.DeductFeeDecorator.checkDeductFee

The owner address that signed it holds zero upokt on chain — confirmed against cosmos/bank/v1beta1/balances. It needed 98305 upokt, roughly 0.098 POKT.

This is not a one-off. Transactions 150 (2026-04-24) and 166 (2026-05-05) are the same failure, code 5, from a different owner address belonging to the same user — an address that still holds exactly 1 upokt today. Those three are the only failed unstakes in the whole table.

Where the gap is

CreateUnstakeTransaction takes the already-signed payload and inserts it as Pending without looking at anything:

export async function CreateUnstakeTransaction(request: CreateUnstakeTransactionRequest) {
return runWithRequestContext(async () => {
const userIdentity = await requireAuth()
log.info('unstake transaction requested', {
ownerAddress: request.transaction.address,
estimatedFee: request.transaction.estimatedFee,
})
const created = await insert({
type: TransactionType.Unstake,
status: TransactionStatus.Pending,
signedPayload: request.transaction.signedPayload,
fromAddress: request.transaction.address,
unsignedPayload: request.transaction.unsignedPayload,
estimatedFee: request.transaction.estimatedFee,
consumedFee: 0,
createdBy: userIdentity,
})
log.info('unstake transaction created', {
transactionId: created.id,
ownerAddress: request.transaction.address,
})
return created
})
}

It logs estimatedFee, so the number is right there — it just isn't compared against anything.

The client doesn't check either. UnstakingProcess.tsx goes from collecting the signature straight to SchedulingTransaction and then to UnstakingProcessStep.Completed:

const createdTransaction = await CreateUnstakeTransaction({
transaction: signedTransaction!,
})
setTransaction(createdTransaction)
// Placeholder: Mark as success
setUnstakingStatus((prev) => ({
...prev,
schedulingTransactionStatus: 'success',
}));
queryClient.invalidateQueries({ queryKey: ['pendingState'] });
queryClient.invalidateQueries({ queryKey: ['nodes'] });
setCurrentStep(UnstakingProcessStep.Completed);

So the modal's terminal state is success, decided before the chain has said anything.

The stake flow does not have this hole — PickStakeAmountStep blocks on balance < minimumStake:

{balance < minimumStake && (
<div className="flex flex-col bg-[var(--bg-surface)] p-0 rounded-[8px]">
<span className="text-[14px] text-[var(--text-tertiary)] p-[11px_16px]">
Token balance is not enough to stake. Transfer more tokens to your wallet to stake $POKT.

The asymmetry is the bug. One path guards the wallet, the neighbouring one doesn't.

What the user actually sees

The transactions list does surface it correctly. failureReasonDisplay maps code 5 plus a log containing insufficient funds to "Insufficient funds to cover the transaction", and getTransactionsByUser filters only on createdBy, so a failed unstake with no linked nodes still shows up in the user's own list.

But that only helps someone who goes looking. The unstake modal already told them it completed, and a notification_event of type unstake was written with nowhere to go — the user in this incident has no rows in notification_channels and no email on their users row. Between the optimistic modal and the silent notification, the failure is discoverable but not delivered.

Suggested direction

Check the owner's spendable balance against the estimated fee before asking for the signature, and refuse with a message that names the shortfall — the fee is already computed at that point, so the comparison is available. Mirroring what the stake flow does in PickStakeAmountStep keeps the two paths consistent.

Worth deciding alongside it:

  • whether CreateUnstakeTransaction should also validate server-side, so the guard doesn't depend on the client alone;
  • whether the unstake modal should stop reporting Completed at scheduling time, since the transaction has not been broadcast yet, let alone accepted;
  • whether a failed transaction deserves an in-app surface that doesn't require a notification channel to be configured.

Context

Found while investigating a user report of unstakes that appeared to do nothing. Nothing is wedged and no state needs repairing — the transaction failed cleanly, with no on-chain effect and no orphaned nodes. Funding the owner address and retrying is enough to unblock the user; this issue is about the next person who hits it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions