Lumi Finance Exploit: ERC-1271 Bypass and Unauthorized Token Approvals in Sodium Smart Accounts
On July 13, 2026, an attacker drained approximately $270,000 in tokens from Sodium smart accounts associated with Lumi Finance on Arbitrum. The on-chain path analyzed here targets the Sodium accounts, not a Lumi liquidity-pool contract.
The exploit required two bugs to work together. First, Sodium accepted an attacker-selected contract as a valid ERC-1271 signer and session key. Second, validateUserOp used attacker-controlled paymasterAndData to grant that same contract unlimited ERC-20 allowances. The allowances persisted even though the later account-execution calls failed.
Incident Overview
Date: July 13, 2026
Network: Arbitrum One
Reported loss: Approximately $270,000
Attack type: ERC-4337 authorization bypass plus validation-phase ERC-20 approval
Affected component: Sodium smart accounts
Sodium implementation:
0xb5BC46dF04dEe31D219E7664122e29EEd9506b8bERC-4337 EntryPoint v0.6:
0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789Attacker EOA:
0xCe1a3BB0b98D0D90C7Dd0620Ab86C9A771888d88Malicious contract:
0x56362412AE17cac443AAFBAb4289946Ad958E8a1Example approval transaction:
0x630654fb1c8914405cf81bb02f091b049f19403a152f624f7b8a00c7724c6604Example drain transaction:
0x1cdba4c3f9c925e6d03ce8d07965a7521ee4e7258f6c5eeae8a7998886c2b9f0
This blog follows one account, 0x0209...16BD, as a concrete example. The same malicious contract acted as its ERC-1271 signer, proposed session key, paymaster, ERC-20 spender, and later token sweeper.
Exploit Mechanics
1. The Actual On-Chain Call Flow
The attacker did not call validateUserOp directly. Sodium’s _requireFromEntryPoint() would reject such a call. Instead, the attacker routed the operation through the EntryPoint configured in the account:
[1] Attacker EOA 0xCe1a...8d88
↓ calls the attack coordinator
[2] Malicious contract 0x5636...8e8a1
↓ handleOps() — selector 0x1fad948c
[3] EntryPoint 0x5FF1...2789
↓ validateUserOp() — selector 0x3a871cdd
[4] Sodium proxy 0x0209...16BD
↓ delegatecall
[5] Sodium implementation 0xb5BC...6b8b
handleOps processed the transaction in two separate loops. EntryPoint first completed account validation, paymaster validation, and validation-data checks for the submitted UserOperations. Only after that validation loop finished did it start the account-execution loop. The trace confirms that every validateUserOp call preceded every executeWithSodiumAuthSession call. This article follows one example operation across those two phases; validation and execution were not interleaved operation by operation.
At [3], msg.sender as seen by the account was the trusted EntryPoint, so the following guard passed:
// [1] Direct attacker calls fail here.
// [2] The exploit passes because EntryPoint is the immediate caller.
require(msg.sender == address(entryPoint()), "account: not from EntryPoint");
The decoded example UserOperation contained:
sender: 0x0209...16BD // [1] Example Sodium account
nonce: 231
callData selector: 0x28f8aa4d // [2] executeWithSodiumAuthSession
proposed sessionKey: 0x5636...8e8a1 // [3] Attacker-controlled contract
authProof: 0x // [4] Empty in the decoded callData
transactions: [] // [5] No wallet actions requested
callGasLimit: 0 // [6] Account subcall will fail with zero gas
verificationGasLimit: 800000 // [7] Validation still has gas
paymasterAndData: 0x5636...8e8a1
|| 095ea7b3
|| fd086b...fcbb9 // [8] Malicious spender + approve selector + USDT0
signature: 0x01
|| 5636...8e8a1
|| 00 // [9] Contract signer + arbitrary one-byte payload
The distinction between [6] and [7] is crucial: the attacker gave validation enough gas to create the approval, while setting the later wallet execution gas to zero.
2. How Arbitrary Signature Bytes Became a Valid Sodium Result
Sodium’s type-0x01 signature format lets the UserOperation name the signer itself:
// Simplified from checkValidSodiumSignature; byte ranges and logic are preserved.
// Signature layout: {0x01}{20-byte signer}{signature bytes}
if (sigType == 0x01) {
signer = address(bytes20(_signature[1:21])); // [1] Supplied by the attacker
signatureBytes = _signature[21:]; // [2] The example contains arbitrary 0x00
valid = SignatureChecker.isValidSignatureNow(
signer, // [3] Malicious contract 0x5636...8e8a1
_hash,
signatureBytes
);
}
The one-byte payload 0x00 is not a valid ECDSA signature. That alone did not make the UserOperation fail. After ECDSA recovery failed, OpenZeppelin’s SignatureChecker made an ERC-1271 call to the supplied signer; because that address was the malicious contract, it could deliberately accept the arbitrary payload:
[1] ECDSA.tryRecover(hash, 0x00)
→ fails
[2] 0x5636...8e8a1.isValidSignature(hash, 0x00)
→ returns 0x1626ba7e
[3] SignatureChecker result
→ valid = true
→ signer = 0x5636...8e8a1
0x1626ba7e is the ERC-1271 magic value for a recognized signature. The raw trace shows the malicious contract returning that exact value.
Sodium then extracted the proposed sessionKey from the same UserOperation’s callData. It did not require this key to be an existing authorized session in the vulnerable branch:
// Simplified pseudocode; the source extracts callData bytes [16:36] directly.
// [1] Attacker-controlled callData supplies the proposed session key.
address sessionKey = extractSessionKey(userOp.callData);
// [2] No configured safe session makes isSafe true by default.
bool isSafe = _safeSession.owner == address(0);
// [3] The external validator is skipped while isSafe is already true.
if (!isSafe) {
isSafe = validator.validateUserOp(userOp) < 2;
}
// [4] Both equal addresses came from attacker-controlled fields.
return sessionKey == signer && isSafe ? 0 : 1;
For the example operation:
sessionKey = 0x5636...8e8a1 // [1] From callData
signer = 0x5636...8e8a1 // [2] From signature
isSafe = true // [3] No safe session configured
result = 0 // [4] Reported as valid to EntryPoint
Therefore, the accurate statement is: the attacker did not provide a valid owner ECDSA signature; it supplied arbitrary bytes that its own ERC-1271 contract deliberately accepted. Sodium then accepted that unauthorized contract as the signer. The successful exploit operation did not return signature failure; the raw validateUserOp output is 32 bytes of zero.
This bypass affected validation only. Had executeWithSodiumAuthSession actually run, it would have called validateAddSessionProof. The attacker avoided that check by assigning zero execution gas after the validation side effect had already occurred.
3. The Approval Side Effect Inside validateUserOp
After _validateSignature, Sodium continued into _approvePaymasterToken without checking the result:
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash); // [1] Returns 0 or 1 in this session path
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
_approvePaymasterToken(userOp); // [2] Runs for both values
}
So yes: inside Sodium, even a signature result of 1 still reaches approve. However, that does not mean an invalid operation can retain the allowance. EntryPoint later decides whether all validation-phase changes remain or are rolled back, as explained in the next section.
The approval helper parsed an exact 44-byte payload:
paymaster address (20 bytes) // [1] Becomes the ERC-20 spender
|| 0x095ea7b3 (4 bytes) // [2] approve(address,uint256) selector
|| token address (20 bytes) // [3] Token chosen by the UserOperation submitter
It then trusted the alleged paymaster to choose the allowance threshold and amount:
// [1] Both values are returned by the untrusted paymaster.
(uint256 miniAllowance, uint256 suggestApproveValue) =
paymaster.getTokenAllowanceCast(payToken);
// [2] Spender is the same untrusted paymaster address.
if (payToken.allowance(address(this), address(paymaster)) < miniAllowance) {
payToken.approve(address(paymaster), suggestApproveValue); // [3] Persistent token approval
}
For the example USDT0 operation, the malicious contract returned:
miniAllowance = 1 // [1] Forces approval when allowance is zero
suggestApproveValue = 2^256 - 1 // [2] Maximum uint256 allowance
spender = 0x5636...8e8a1 // [3] Malicious contract
token = 0xFd086b...fCbb9 // [4] USDT0
The trace then shows the Sodium account calling USDT0 approve(0x5636...8e8a1, type(uint256).max), with the token returning true.
4. Where EntryPoint Would Revert, and Why It Did Not
After validateUserOp and paymaster validation return, EntryPoint v0.6 parses the account’s validationData. For an ordinary non-aggregated UserOperation, it expects the aggregator field to be address(0):
// [1] Low 160 bits of validationData are interpreted as an aggregator address.
(address aggregator, bool outOfTimeRange) =
_getValidationData(validationData);
// [2] expectedAggregator is address(0) for this handleOps path.
// [3] A Sodium result of 1 parses as address(1), triggering AA24.
if (expectedAggregator != aggregator) {
revert FailedOp(opIndex, "AA24 signature error");
}
The rollback branch is:
[1] Sodium signature check returns 1
↓
[2] Sodium still calls approve temporarily
↓
[3] EntryPoint parses 1 as aggregator = address(1)
↓
[4] EntryPoint reverts with AA24
↓
[5] The whole handleOps transaction reverts; approval is erased
The exploited branch was different:
[1] Unauthorized ERC-1271 signer makes Sodium return 0
↓
[2] Sodium grants the attacker-controlled allowance
↓
[3] Malicious paymaster validation also returns 0
↓
[4] EntryPoint sees aggregator = address(0); no AA24
↓
[5] EntryPoint attempts account execution with callGasLimit = 0
↓
[6] The account subcall fails out of gas; innerHandleOp records opReverted
↓
[7] handleOps completes; the validation-phase approval remains
This is why the signature bypass was necessary. Merely reaching approve after a failed Sodium check would not have been enough: AA24 would have reverted it. The attacker needed Sodium to report 0, then relied on innerHandleOp converting the failed account subcall into an opReverted result rather than reverting the entire handleOps transaction. EntryPoint’s surrounding try/catch is an additional isolation layer for cases where innerHandleOp itself reverts; it was not the mechanism that handled this zero-gas account call.
5. The Separate Drain Transaction
The approval transaction did not itself transfer USDT0. It created a durable spending right for the malicious contract.
In the later drain transaction, the trace shows this exact sequence for the example account:
[1] USDT0.balanceOf(0x0209...16BD)
→ 99,577,041 base units
[2] USDT0.allowance(0x0209...16BD, 0x5636...8e8a1)
→ 2^256 - 1
[3] USDT0.transferFrom(
0x0209...16BD, // token owner
0x5636...8e8a1, // recipient and approved spender
99,577,041 // full observed balance
)
→ true
The same drain transaction also contains token swaps through Uniswap V3 and WETH withdrawal calls. The security boundary had already been lost at [2]: once the unlimited allowance persisted, transferFrom no longer needed another wallet signature or UserOperation.
Root Cause Analysis
The exploit was not caused by one isolated mistake. It depended on two trust-boundary failures:
Broken session authorization: Sodium accepted equality between a proposed
sessionKeyand a signer when both were chosen by the same untrusted UserOperation. A permissive attacker-controlled ERC-1271 contract could therefore make_validateSignaturereturn0without owner authorization.Attacker-directed state change during validation: Sodium turned untrusted
paymasterAndDatainto a persistent ERC-20 approval. The alleged paymaster selected the token allowance threshold, the approval amount, and itself as spender.
Calling the approval helper even when _validateSignature returned 1 was unsafe design, but it was not by itself sufficient for this successful exploit. EntryPoint would have raised AA24 and rolled the approval back. The authorization bypass was what changed the result to 0 and allowed the approval to survive.
Recommended Fixes
Remove token approvals from
validateUserOp. Any allowance must be an explicit owner-authorized execution action, not a validation side effect derived frompaymasterAndData.Fail closed on session authorization. Require an existing authorized session or verify the complete owner-authorized session proof during validation; never authenticate by comparing two attacker-supplied fields.
Add adversarial ERC-4337 tests. Cover permissive ERC-1271 signers, untrusted paymasters returning maximum allowances,
validationDatavalues0and1,AA24rollback, zero-gas execution, and a latertransferFromattempt.
Conclusion
The attacker did not provide a valid owner ECDSA signature. It supplied arbitrary bytes that were invalid under ECDSA but deliberately accepted by the malicious ERC-1271 contract. The attacker-selected session key matched that contract, so Sodium returned validationData = 0.
That result let EntryPoint retain an unlimited token approval created during validation. The subsequent zero-gas execution failed without reverting the outer batch, and the attacker exercised the surviving allowance later through transferFrom.
The core account-abstraction lesson is simple: validation must not create attacker-controlled, persistent spending rights. Authentication inputs remain adversarial until they are tied to an authority that the account owner established independently of the current UserOperation.

