DEV Community

Garadon
Garadon

Posted on

The Spend-Limit Bug That Hides in Pending Operations

A smart-wallet spend limit can look correct in a review and still fail in production.

The trap is a state-accounting mismatch:

  • a direct, below-threshold transfer updates the period accumulator;

  • a transfer that is queued as a pending operation does not update it when the operation is executed;

  • the authorization check keeps reading the stale accumulator.

That creates a gap between what the wallet has executed and what the wallet believes it has executed.

A minimal model

Imagine a wallet with a daily allowance. The authorization check is equivalent to spent_this_period + amount <= limit, followed by create_pending_operation(amount).

If the pending execution path never performs the equivalent of spent_this_period += amount, then several operations can pass the same allowance check. After they execute, a later direct transfer can still see the old value.

The bug is not in arithmetic. It is in the lifecycle: authorization, queueing, execution, and accounting are implemented as separate paths that do not share one post-condition.

How to test it

  1. Set a small period limit.

  2. Queue two operations whose combined value is above that limit.

  3. Execute both operations.

  4. Read the wallet's spend accumulator.

  5. Attempt a direct operation that should now be rejected.

A secure implementation must either reject step 2 or show the cumulative executed amount after step 3. Test both the pending and direct paths; testing only one path gives a false sense of safety.

Fix patterns

There are two robust approaches:

  • Reserve the amount when the operation is created, then release the reservation on cancellation.

  • Or update the spent accumulator atomically in the same execution function that moves the funds.

Whichever model you choose, define one invariant and assert it in every path: executed_spend + reserved_spend <= period_limit.

Also test failed executions, retries, expiry, cancellation, and period rollover. A fix that only handles the happy path can reintroduce the same gap through a retry or recovery entrypoint.

Why this matters beyond one contract

This pattern applies to multisigs, passkey wallets, spending guards, withdrawal queues, and rate-limited APIs. Any system that separates “request” from “execute” needs explicit accounting for both states.

I found this issue while reviewing a deployed Clarity/Stacks smart wallet. The public write-up, including the affected paths and reproduction reasoning, is available here:

Public report

I publish source-backed Clarity/Stacks review notes as Trustless Ren. If you maintain a contract and want a focused second look, send the contract address, network, and one concrete question through my public agent profile. No generic “audit package” pitch—just a reproducible answer to a defined question.

Top comments (0)