On 19 October 2026, at 09:42:35 UTC, an attacker on Ethereum mainnet started a two-transaction attack against a smart wallet system built on the Argent design. One minute later it was over. SlowMist puts the loss at about $59.8K. The money did not leave through some exotic DeFi trick. It left because two of the most basic promises a smart wallet makes were not enforced in code: a recovery takes time, and one signature authorizes one action.
This post walks through both flaws, how they were combined, what the on-chain data shows, and the checks we run on any wallet or account abstraction codebase that has guardians, time locks or relayed signatures.
0x254a2e49) accepted a crafted recovery period. Adding it to the current time wrapped the deadline to a timestamp in the past, so a guardian-backed recovery could be finalized in the same transaction and the wallet owner was changed immediately.0xaacaaf88) as used. The same signed bytes executed 19 times in the first transaction and 9 times in the second.The Argent architecture splits a smart wallet into a minimal wallet contract and a set of modules that are allowed to act on it. Two of those modules matter here.
Recovery. The owner nominates guardians. If the owner loses their key, guardians can approve a recovery that assigns a new owner. The recovery does not take effect at once. It sits in a pending state until a deadline passes, and Argent’s own wallets use a 48-hour window. That delay is the entire security model of social recovery: if a guardian is compromised or colludes, the real owner still has time to notice and cancel.
Execution. The owner signs a description of what they want done, and a relayer submits it so the owner does not need ETH for gas. The module checks the signature and then makes the wallet perform the call. For this to be safe, every signed message has to be single-use, which is normally done with a nonce or a record of hashes already consumed.
Both rules are about state the module keeps for itself. The delay only works if the stored deadline really lies in the future. The signature only works once if the module writes down that it was spent. In this incident, neither write was correct.
The recovery module exposed a setter for the recovery period with no access control. An attacker could choose the period. The deadline was then computed from the current block time plus that period, and a crafted value made the result wrap around to a timestamp that was already behind block.timestamp.
From that point the module believed the waiting period had already elapsed. A recovery that carried a valid guardian signature could be started and finalized inside one transaction, and the wallet’s owner changed with no window for anyone to react.
Solidity 0.8 reverts on overflow by default, so a wrap needs one of three things: code compiled with an older compiler, arithmetic inside an unchecked block, or a cast to a narrower type such as uint64 after the addition. We have not seen the verified source for this module, so we cannot say which one applied. All three show up regularly in wallet code because timestamps are often packed into small types to save storage.
Here is a simplified, illustrative version of the pattern. It is not the deployed code:
// Simplified illustration, not the exploited contract.
struct Config {
uint64 recoveryPeriod;
}
struct Recovery {
address newOwner;
uint64 executeAfter;
}
mapping(address => Config) public config;
mapping(address => Recovery) public recoveries;
// BUG: no access control, no bounds.
function setRecoveryPeriod(address wallet, uint64 period) external {
config[wallet].recoveryPeriod = period;
}
function startRecovery(address wallet, address newOwner, bytes calldata guardianSig) external {
_verifyGuardian(wallet, newOwner, guardianSig);
unchecked {
// A huge `period` makes this wrap to a value below block.timestamp.
recoveries[wallet] = Recovery(
newOwner,
uint64(block.timestamp) + config[wallet].recoveryPeriod
);
}
}
function finalizeRecovery(address wallet) external {
Recovery memory r = recoveries[wallet];
require(r.newOwner != address(0), "no recovery");
require(block.timestamp > r.executeAfter, "too early"); // passes immediately
_setOwner(wallet, r.newOwner);
delete recoveries[wallet];
}
The guardian signature check was working. That is what makes this bug instructive: the attacker did not have to forge anything. They only had to remove the time that the design relied on to catch a bad recovery.
The second flaw sat in the execution module. It verified that the payload was signed by an authorized key, executed it, and then forgot about it. No nonce was incremented and no hash was stored, so resubmitting the same bytes passed every check again.
A simplified, illustrative sketch of the bug class:
// Simplified illustration, not the exploited contract.
function execute(address wallet, bytes calldata data, bytes calldata sig) external {
bytes32 digest = keccak256(abi.encode(address(this), block.chainid, wallet, data));
require(_isValidSigner(wallet, digest, sig), "bad sig");
// BUG: nothing records that `digest` has been used.
IWallet(wallet).invoke(data);
}
Our own decode of the event logs lines up with SlowMist’s counts. The first transaction emits 19 execution events from module 0x4e50...7394 and 19 WETH deposits through the Uniswap V2 router. The second emits 9 of each.
Both transactions were sent by the attacker EOA 0xc106...2b02 to an unverified attack contract at 0x3C58...B310.
0xF490...8ef5 emitted a single set of recovery events in this transaction, consistent with SlowMist’s description of a same-transaction recovery. The replayed payload then made victim wallet 0xe545...8f66 buy MARK through the Uniswap V2 router, round after round, in the MARK/WETH pool 0x6f23...b6F3, while the attack contract sold into those buys. Summing the logs, the wallet pushed about 20.18 WETH through the router and ended the transaction holding about 133.1M MARK. The attack contract finished with 48,778.50 USDC, after swapping its WETH in the main USDC/WETH pool.0x5a79...5440. That wallet spent about 9,405 USDC and ended with about 318.7M MARK. The attack contract received another 1,877.42 USDC.In short, the victims were forced to buy an illiquid token at prices the attacker was moving, and the attacker kept the difference. The wallets were left holding MARK that could not be sold back for anything close to what they paid.
Our log totals add up to about 50.7K USDC received by the attack contract. SlowMist’s headline figure is $59.8K. We have not reconciled the gap; it may come from pricing differences, from the value of MARK left behind, or from flows we did not trace (for example native ETH movements that do not emit token events). We quote SlowMist’s figure as the reported loss.
Time locks are written once and never attacked. Teams test that a recovery cannot finalize before the deadline. Very few test what happens when the deadline itself is computed from hostile input. The Notional V1 overflow we covered last month is the same lesson in a lending protocol: narrow integer types plus unchecked math turn “impossible” values into reachable ones.
Configuration setters get less review than core logic. A function named setRecoveryPeriod looks like admin plumbing. In a modular wallet it is a security parameter, and leaving it callable by anyone is no different from leaving setOwner open. It is the same family as the missing authorization we described in the Order Factory exploit.
Replay protection is often delegated and then lost. Many wallets rely on a relayer manager, a shared nonce contract or the wallet itself to track nonces. When a new module is written, or an old one forked, the nonce write is easy to drop, and nothing fails in a happy-path test.
uint256 with checked arithmetic and only then narrow, using SafeCast. Add require(executeAfter > block.timestamp) right after you store it.usedHashes[digest] = true and require it to be false.type(uint64).max?These checks are part of our smart contract security checklist, and they take an afternoon to run against a module. That is a lot cheaper than $59.8K.
0xbfed8d33561ed5f9cbd0f4e62a5bbd20187a64f3e22180148dcb58ed6ebaa7950xa0ee8f914205a948c79eca039b7d8265537eb220472c9be1455d3321f1bd571b0xc10609ce5c032db92e562e463b1234aab0872b020x3c5884377cca79ff221936aea4a5df43293fb3100xe545b9a69bff1bf0ed2e922551dda4afc8ee8f66, 0x5a79e5772493cf4329682d202aa2d28f85a854400xf490557d67d934440677a4f16793d5ef319b8ef5, 0x4e50adfc953551286bbd7fee2b2b846d1d2273940x6f23d2fedb4ff4f1e9f8c521f66e5f2a1451b6f3Nothing in this incident required breaking cryptography. The guardian signature was valid and the owner signature was valid. What failed was the bookkeeping around them: a deadline that could be pushed into the past and a signature that was never crossed off. In wallet code, those few lines of state are the security model.
Building a smart wallet, a recovery module or an ERC-4337 account? Contact Panda Security for a review before your users trust it with their keys, or check our audit pricing.
A vault that lets callers pass in their own contract addresses and then books whatever those contracts return is a drain waiting to happen. Here is how this smart contract vulnerability works, why decompiled bytecode still gives it away in minutes, and the checklist PandaSecurity uses when auditing accounting logic.
A recovery bot finalized stale Sui Bridge withdrawals and paid 86 ETH, 109K USDT and 0.9 WBTC to the Cetus exploiter. What went wrong and what to fix.
How a permissionless accept() and a buyback with no slippage limit let an attacker drain 2.31 ETH from the KolanHook Uniswap v4 hook on Ethereum.
UNCX's token vesting contract lost roughly $87.7K when an attacker exploited a share-accounting flaw in release(), topUpVesting(), and cancel(). We break down the root cause, the exact attack sequence, and what builders should take away from it — without the jargon.