Originally published at panda-security.xyz.
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.
TL;DR
- Target: an Argent-style modular smart wallet system (recovery module plus execution module). The affected deployment was not the official Argent wallet as far as public reports show; SlowMist describes it only as "Argent-style".
- Chain: Ethereum mainnet, blocks 26225798 and 26225803.
- Loss: about $59.8K according to SlowMist.
-
Flaw 1: an unrestricted setter in the recovery module (selector
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. -
Flaw 2: the execution module never marked a signed payload (selector
0xaacaaf88) as used. The same signed bytes executed 19 times in the first transaction and 9 times in the second. - Monetization: the replayed executions made the victim wallets buy MARK over and over in a thin MARK/WETH Uniswap V2 pool while the attacker traded around them.
How Argent-Style Wallets Are Supposed to Work
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.
Root Cause 1: A Deadline That Lands in the Past
What SlowMist found
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.
Why the wrap is possible
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.
Root Cause 2: One Signature, Twenty-Eight Executions
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.
How the Attack Was Sequenced
Both transactions were sent by the attacker EOA 0xc106...2b02 to an unverified attack contract at 0x3C58...B310.
-
Transaction 1 (09:42:35 UTC, block 26225798). According to Olympix's trace review, the attack contract took a 0.5 WETH flash loan from Balancer. The recovery module
0xF490...8ef5emitted 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 wallet0xe545...8f66buy MARK through the Uniswap V2 router, round after round, in the MARK/WETH pool0x6f23...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. -
Transaction 2 (09:43:35 UTC, block 26225803). The same loop ran 9 times against the second victim wallet
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.
Why This Class Keeps Appearing
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.
How to Fix It
For recovery modules
- Restrict who can change security parameters. Only the wallet owner (through the wallet itself) should be able to change the recovery period, and the change should itself be subject to the existing delay.
- Bound the value. Enforce a minimum and maximum period, for example 24 hours to 30 days, and reject anything outside.
-
Compute deadlines safely. Do the addition in
uint256with checked arithmetic and only then narrow, usingSafeCast. Addrequire(executeAfter > block.timestamp)right after you store it. - Never start and finalize in the same block. A recovery that can begin and complete in one transaction has no delay by definition. Store the start block and require it to be strictly earlier.
For execution modules
-
Make every signature single-use. Increment a per-wallet nonce before the external call, or store
usedHashes[digest] = trueand require it to be false. - Bind the domain. Include chain id, module address, wallet address, nonce and an expiry in the signed digest, ideally with EIP-712.
- Write state before the call. Consume the nonce first, then execute, so a reentrant path cannot reuse it.
The Audit Checklist We Use for Smart Wallets
- List every parameter that affects recovery, guardians, limits or delays. Who can change each one, and is the change itself delayed?
- For every stored timestamp, can the input that produces it be influenced by a caller? What happens at
type(uint64).max? - Can any privileged state change begin and complete in a single transaction?
- For every signature path: where is the nonce or used-hash write, and does it happen before the external call?
- Write a Foundry test that submits the same signed payload twice. It should revert on the second call.
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.
Key Addresses
- Exploit tx 1:
0xbfed8d33561ed5f9cbd0f4e62a5bbd20187a64f3e22180148dcb58ed6ebaa795 - Exploit tx 2:
0xa0ee8f914205a948c79eca039b7d8265537eb220472c9be1455d3321f1bd571b - Attacker:
0xc10609ce5c032db92e562e463b1234aab0872b02 - Attack contract:
0x3c5884377cca79ff221936aea4a5df43293fb310 - Victim wallets:
0xe545b9a69bff1bf0ed2e922551dda4afc8ee8f66,0x5a79e5772493cf4329682d202aa2d28f85a85440 - Vulnerable modules:
0xf490557d67d934440677a4f16793d5ef319b8ef5,0x4e50adfc953551286bbd7fee2b2b846d1d227394 - MARK/WETH Uniswap V2 pool:
0x6f23d2fedb4ff4f1e9f8c521f66e5f2a1451b6f3
Sources
- SlowMist alert on X: root cause, loss estimate, addresses and transactions
- SlowMist Hacked database entry
- Olympix post-mortem: transaction timing, flash loan and pool details
- PANews report (Chinese)
- Ethereum mainnet transaction receipts for both exploit transactions, decoded by the PandaSecurity Team
Closing Thoughts
Nothing 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.
Top comments (0)