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.
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:
Five seconds later,
code 5, and the raw log from the chain:The owner address that signed it holds zero
upokton chain — confirmed againstcosmos/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
CreateUnstakeTransactiontakes the already-signed payload and inserts it asPendingwithout looking at anything:igniter/apps/middleman/src/actions/Unstake.ts
Lines 86 to 113 in fa45761
It logs
estimatedFee, so the number is right there — it just isn't compared against anything.The client doesn't check either.
UnstakingProcess.tsxgoes from collecting the signature straight toSchedulingTransactionand then toUnstakingProcessStep.Completed:igniter/apps/middleman/src/app/app/unstake/components/ReviewStep/UnstakingProcess.tsx
Lines 109 to 123 in fa45761
So the modal's terminal state is success, decided before the chain has said anything.
The stake flow does not have this hole —
PickStakeAmountStepblocks onbalance < minimumStake:igniter/apps/middleman/src/app/app/stake/components/PickStakeAmountStep/index.tsx
Lines 77 to 80 in fa45761
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.
failureReasonDisplaymapscode 5plus a log containinginsufficient fundsto "Insufficient funds to cover the transaction", andgetTransactionsByUserfilters only oncreatedBy, 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_eventof typeunstakewas written with nowhere to go — the user in this incident has no rows innotification_channelsand no email on theirusersrow. 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
PickStakeAmountStepkeeps the two paths consistent.Worth deciding alongside it:
CreateUnstakeTransactionshould also validate server-side, so the guard doesn't depend on the client alone;Completedat scheduling time, since the transaction has not been broadcast yet, let alone accepted;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.