Guardian's review of Ethena Pay Updates for Ethena, published July 2026. The report records 81 findings across 2 review rounds, including 3 high and 14 medium.
- Published
- Review window
- June 10 to 29, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum
- Sector
- Stablecoins
- 0 Critical
- 3 High
- 14 Medium
- 39 Low
- 25 Informational
Findings 81
Main Review
53 findings · June 10 to 29, 2026-
H-01 High Target signer can cancel its own removal Validation Resolved
Description
cancelRemoveOwneraccepts a cancellation signature from any registered signer. It then callsLibAuth.cancelRemoveOwner(actionId)without checking whether the verifiedsignerKeyis the owner pending removal.This means the owner being removed can sign
CancelRemoveOwnerwhile it is still registered. If that signer is compromised, the attacker can cancel its own pending removal during the 24 hour cooldown. The honest signer can start removal again, but the attacker can keep cancelling each pending action as long as the compromised signer remains registered.cancelRemovePasskeyhas the same missing exclusion before it callsLibAuth.cancelRemovePasskey(actionId). Therefore a compromised passkey can also cancel a pending removal for its own coordinates.This does not give an outsider access to a wallet. The attacker must already control a registered signer. However, the protocol relies on signer removal to contain a stolen or phished signer key. Because the target signer can cancel each pending removal, the compromised signer can remain registered and keep authorizing wallet actions, including direct mode operations and direct withdrawals once direct mode is active.
Recommendation
Do not fix this with a blanket rule that the removal target can never cancel. That rule protects against a compromised signer blocking its own removal, but it creates the opposite failure for two-signer wallets: a compromised signer could initiate removal of the honest signer and the honest signer would be unable to cancel the pending removal.
Instead, treat signer removal as a contested action. Store the signer that initiated the removal and add explicit conflict handling before
LibAuth.cancelRemoveOwner(actionId)orLibAuth.cancelRemovePasskey(actionId)deletes the pending action.For wallets with three or more signers, require cancellation or final execution to be authorized by a signer that is neither the removal target nor the initiator, or require a small quorum. For two-signer wallets, do not allow one signer to unilaterally remove the other without recovery-admin, guardian, or stronger multi-factor involvement. If the removal target objects, move the action into a contested state instead of simply deleting it or letting either party unilaterally win.
Also quarantine pending-removal signers from security-sensitive signer-management actions, such as approving new signers or cancelling direct mode, unless the action is part of the contested-removal resolution. This keeps the compromised signer from preserving control through a replacement key while still avoiding a design where the honest target cannot defend against malicious removal.
-
H-02 High Pause blocks revoke, not ERC-1271 fills Unexpected Behavior Partially resolved
Description
When the factory's global pause is active,
LibDirectMode.enforceUserNotBlocked()reverts if the wallet is not already in direct mode.ExternalApproveAndPreAuthorizeHashFacet.revokePreAuthorizedHashcallsenforceUserNotBlocked, so a user who needs to cancel a Fusion+ pre-authorization during a global emergency cannot do so unless they had already entered direct mode before the pause. The alternative cleanup path,clearStaleApproval, has no pause check but requiresblock.timestamp >= entry.expiresAt, meaning it is only callable after theapprovalDeadlinehas already passed. Meanwhile,ERC1271Facet.isValidSignatureis a view function with no pause guard and returnsMAGIC_VALUEfor any pre-authorized hash regardless of pause state. The ERC-20 approval granted byexternalApproveAndPreAuthorizeHashlives on the token contract, entirely outside the wallet's pause domain. A Fusion+ resolver can therefore successfully execute a fill (tokentransferFromplusisValidSignaturemagic value) during a global pause at prices the user can no longer cancel. The user's 1-hour direct-mode cooldown exists but provides no protection within that window.During a global pause, users cannot revoke pre-authorized Fusion+ orders (
revokePreAuthorizedHashis paused-blocked) while resolvers can still fill them (isValidSignatureand ERC-20 allowances are not paused). Users may lose funds at stale prices during an emergency pause window.Recommendation
Add a pause check inside
ERC1271Facet.isValidSignaturefor pre-authorized hashes: if the wallet's factory is globally paused, returnERC1271_INVALID(or revert) for pre-authorized hash lookups, preventing fills during emergencies. Additionally, consider makingrevokePreAuthorizedHashavailable without theenforceUserNotBlockedgate (treat it likecancelRecovery, always accessible, since it is purely protective), or at minimum bypass the pause gate for single-sig revocations the way direct-mode operations bypass it. -
H-03 High Pending-removal signer can install a replacement Unexpected Behavior Resolved
Description
approveAddOwneraccepts a signature from any signer that is still registered. It does not check whether that signer is already pending removal. Therefore, a compromised owner or passkey can keep approving signer-management actions during the cooldown that is supposed to contain it.For example, assume Alice has a wallet with an honest passkey and an address owner whose private key has been phished. Alice uses the passkey to initiate removal of the compromised owner. During the 24 hour removal cooldown, the attacker uses the compromised owner to initiate adding a new attacker-controlled owner. Once the addition cooldown has elapsed, the attacker approves that pending addition with the compromised owner. Alice can still execute the original removal, but only the old compromised owner is removed. The replacement owner remains registered and can continue authorizing wallet operations.
This is different from allowing a signer to cancel its own removal. Even if signer-removal cancellation is redesigned as a contested action, the attacker can preserve access through a replacement signer unless pending-removal signers are restricted from approving new signer-management actions. The removal succeeds, but it no longer removes the attacker's control of the wallet.
Recommendation
Treat pending removal as an authorization quarantine for actions that can preserve or expand control. Once an owner or passkey is queued for removal, that signer should not be able to initiate or approve new signers, approve replacement keys, change recovery configuration, or perform other security-sensitive signer-management actions.
At minimum, make
initiateAddOwner,approveAddOwner,initiateAddPasskeyandapproveAddPasskeyreject signatures from signer keys that are currently pending removal. A stronger fix is to centralize this check immediately afterLibAuth.verifyOwnerSignaturereturns the signer key, then apply it to every signer-management function where a pending-removal signer should no longer be trusted.Do not use this quarantine to prevent the removal target from objecting to its own removal in every case. That creates the opposite two-signer failure where a compromised signer can initiate removal of the honest signer and the honest signer cannot stop it. Cancellation and objection should follow the contested-removal design: with three or more signers, require a third signer or quorum; with two signers, require recovery-admin, guardian, or another stronger recovery mechanism to resolve the dispute.
-
M-01 Medium Preauthorized ERC1271 hashes ignore deadlines Validation Resolved
Description
ERC1271Facet.isValidSignaturereturns the ERC-1271 magic value wheneverLibPreAuthorizedHash.isAuthorized(hash)is true. It does not check an expiry timestamp before accepting the hash.externalApproveAndPreAuthorizeHashaccepts anapprovalDeadline, but that deadline is only stored for later stale-approval cleanup. The preauthorized hash itself is stored as a boolean inLibPreAuthorizedHash, so it remains valid until a separate cleanup or revocation transaction clears it.Consequently, an external protocol that relies on the wallet's ERC-1271 response can continue treating an expired order or intent hash as valid. If the related token approval has not been cleared yet, stale settlement can remain possible after the wallet-level approval deadline.
Recommendation
Store an expiry timestamp with each preauthorized hash and make
isValidSignaturereturn invalid after that timestamp. The expiry should be written whenexternalApproveAndPreAuthorizeHashauthorizes the hash.When an expired hash is detected, either reject it without changing state or expose a cleanup function that clears both the token approval metadata and the ERC-1271 authorization. Avoid keeping the ERC-1271 authorization valid after the approval deadline has passed.
-
M-02 Medium Direct spends bypass recipient policy Unexpected Behavior Partially resolved
Description
BatchExecutor.batchExecutechecksblockedandapprovedRecipientsbefore it forwardsspendDailyAllowanceorspendDailyAllowanceEthcalldata to a wallet. Those checks only run insideBatchExecutor.The wallet spend functions are still externally callable.
spendDailyAllowanceverifies the executor signature over(token, to, amount, spendNonce, deadline), consumes the spend nonce, checks the daily limit and transfers tokens toto. It does not requiremsg.senderto beBatchExecutor. It also does not check the BatchExecutor allowlist or blocklist before transferring funds.For example, assume the executor signs a card spend to a merchant and that merchant is later blocklisted before settlement. If a relayer submits the spend through
BatchExecutor, the batch contract rejects the blocked recipient. The same signed calldata can still be sent directly to the wallet proxy. The wallet accepts the executor signature and transfers funds to the blocked merchant because the recipient policy is not enforced where the transfer happens.Consequently, the configured recipient policy is optional. It protects only spends routed through
BatchExecutor, not all executor-signed allowance spends.Recommendation
Enforce recipient policy in
spendDailyAllowanceandspendDailyAllowanceEth, since those functions perform the actual token and ETH transfers.If recipient policy must remain in a gateway contract, require allowance spend functions to be called by the configured gateway and bind that gateway address into the signed spend digest. Do not rely on
BatchExecutorchecks unless direct wallet calls are blocked or enforce the same policy. -
M-03 Medium ProxyAdmin upgrade can bypass beacon timelock Unexpected Behavior Resolved
Description
The wallet beacon is owned by the factory proxy address. The normal factory functions enforce a 48-hour delay by requiring
scheduleUpgradefirst andexecuteUpgradeafterUPGRADE_DELAY.The factory proxy is itself upgradeable through
ProxyAdmin.upgradeAndCall. A replacement factory implementation can run code in the proxy's storage context during that same call. Since the proxy address is the beacon owner, the replacement implementation can callbeacon.upgradeToimmediately and change the wallet implementation without using the factory's 48-hour beacon-upgrade schedule.This is not a public exploit as it requires
ProxyAdminauthority or a governance-approved factory implementation upgrade. However, it still weakens the documented upgrade model because the docs describe theProxyAdmintimelock and the factory's internal 48-hour delay as layered protection for wallet implementation changes. In practice, a factory implementation upgrade can make the wallet beacon change effective after only theProxyAdmindelay.Recommendation
Treat factory implementation upgrades as full wallet-system upgrades. Set the
ProxyAdmintimelock delay to at least the factoryUPGRADE_DELAYand document that a factory upgrade can affect the wallet beacon immediately. For stronger isolation, move beacon ownership to a non-upgradeable timelock-owned controller. Another option is to require a separate on-chain check before any factory implementation can upgrade the beacon. -
M-04 Medium Pending recovery stuck by executor rotation Unexpected Behavior Resolved
Description
executeRecoveryclears the pending recovery and then callsLibAuth.addOwnerDirectfor address signers.addOwnerDirectrechecks the current factory executor and reverts if the pending owner is now the executor.If the factory executor changes during the recovery delay and becomes the pending owner,
executeRecoveryreverts. The earlierclearPendingRecoverycall is rolled back with the revert, so the invalid pending recovery remains active. SinceinitiateRecoveryrejects any new recovery whilependingActiveis true, the recovery admin cannot start a replacement recovery. Only an existing wallet owner can cancel the stale recovery.This is a bad failure mode for recovery because the feature is specifically needed when the user may not have access to an existing signer.
Recommendation
Handle invalid pending address recoveries without leaving the wallet stuck. If the pending owner equals the current executor at execution time, clear the pending recovery and emit an invalidation event instead of reverting. Alternatively, allow recovery admins to cancel or replace a pending recovery once it has become invalid or after the recovery delay has elapsed.
-
M-05 Medium Stale recoveries remain executable Unexpected Behavior Resolved
Description
initiateRecoverystores only whether a recovery is pending, the new signer and the initiation timestamp. It does not store the initiating recovery admin, the recovery-admin configuration hash or an expiry time.executeRecoverythen checks only that a recovery is pending and that the 10-day minimum delay has elapsed.Consequently, removing a recovery admin does not invalidate a recovery that the admin already initiated. The pending recovery also remains executable indefinitely after the delay, even though the security documentation says pending timelocked actions, including recovery, expire after 7 days.
For example, a default recovery admin is compromised and initiates recovery to add an attacker-controlled signer. Governance later removes that admin from the default recovery admin list, but the old pending recovery remains valid. If the wallet owner cannot cancel it with an existing signer, anyone can execute the stale recovery after the delay and add the attacker-controlled signer.
Recommendation
Store the recovery initiator, the recovery-admin configuration hash and an expiry timestamp when recovery is initiated. At execution time, require the stored initiator to still be an effective recovery admin under the same configuration. Reject pending recoveries that are past their expiry and consider cancelling affected pending recoveries when factory default recovery admins change.
-
M-06 Medium Re-added signers can reuse old unused signatures Unexpected Behavior Resolved
Description
Signer nonces are keyed by owner address or passkey coordinates. Removing an owner or passkey deletes it from the active signer array, but the removal does not invalidate the signer's nonce or otherwise mark a new signer generation.
If the same signer is later re-added, its old nonce value is still present. Any old signature that was created before removal, used that still-current nonce and has not expired can become valid again after re-addition.
This can resurrect stale approvals, withdrawals or arbitrary-call authorizations across signer lifecycle changes. The signed parameters cannot be changed, but the user may have expected removal to invalidate all outstanding authority from that signer.
Recommendation
Invalidate signer authorization state when a signer is removed. A simple fix is to increment
signerNonces[removedSignerKey]during owner and passkey removal. A stronger fix is to add a signer generation counter and include it in every signed struct, so removing and re-adding the same address or passkey starts a fresh authorization generation. -
M-07 Medium Pre-authorized hashes survive signer removal Validation Partially resolved
Description
Pre-authorized hashes are stored as wallet-wide booleans. The storage does not record which signer authorized the hash and
ERC1271Facet.isValidSignaturereturns the ERC-1271 magic value for any authorized hash without checking current signer membership.Consequently, removing a compromised signer does not invalidate hashes that signer already pre-authorized. If the authorization also created an ERC-20 allowance, the spender can keep using the pre-authorized order and allowance after the signer has been removed from the wallet.
This is distinct from stale live signatures: live signatures from a removed signer fail because the signer is no longer registered. Pre-authorized hashes bypass live signer verification entirely.
Recommendation
Store signer attribution and a signer epoch with each pre-authorized hash.
isValidSignatureshould reject pre-authorized hashes whose authorizing signer is no longer registered or whose signer epoch changed after authorization.Also add a wallet-wide pre-authorization epoch or a signer-scoped revocation mechanism so users can invalidate all pre-authorized hashes created by a compromised signer during signer rotation.
-
M-08 Medium Live ERC-1271 bypasses executor policy Unexpected Behavior Resolved
Description
ERC1271Facet.isValidSignatureaccepts any live signature from a registered wallet signer for any hash supplied by an external protocol. It does not require executor co-signature, daily allowance checks, recipient policy, direct-mode policy or any wallet-side parsing of the external protocol payload.The normal wallet spending model has two "locks". First, the user signer says "yes". Second, the executor says "yes". That is the intended safety model for wallet fund movement.
Permit2 changes the shape of the action. The wallet can first approve Permit2 to spend tokens. When this approval is created through the wallet's normal approval functions, it requires both the signer and the executor. After Permit2 has that token approval, however, Permit2 can move funds if it receives a valid ERC-1271 signature from the wallet.
The problem starts at
src/facets/ERC1271Facet.ERC1271Facet.isValidSignatureaccepts any live signature from a registered wallet signer. It does not ask the executor. It does not check daily limits. It does not check recipient policy. It does not parse the Permit2 spend.The sequence is:
- The wallet gives Permit2 an ERC-20 allowance through an owner-and-executor approved wallet action.
- Later, a registered signer signs a Permit2 message.
- Permit2 asks the wallet whether the signer signature is valid.
- The wallet answers yes through ERC-1271.
- Permit2 spends from the wallet using the existing allowance.
- The executor never approves that later Permit2 spend.
This matters for shared spenders and external settlement protocols that can consume ERC-1271 signatures after an allowance exists. Permit2 is the clearest shared-spender example. Once the wallet has granted an ERC-20 allowance to the Permit2 contract, a single registered signer can sign a Permit2
SignatureTransferdigest for an arbitrary Permit2 spender. That spender can then callPermit2.permitTransferFromand move funds from the wallet without any executor signature on the actual spend.The same issue exists for Permit2
AllowanceTransfer.permit. A registered signer can sign a Permit2 allowance permit that creates a durable(wallet, token, spender)sub-allowance inside Permit2. The spender can then callPermit2.transferFromuntil the signed amount is depleted or the Permit2 expiration passes. The wallet does not register or clear that sub-allowance.The batch variants have the same trust boundary. A single registered signer signature over
PermitBatchTransferFromcan move multiple approved tokens through Permit2 and a single registered signer signature overAllowanceTransfer.PermitBatchcan create multiple durable Permit2 sub-allowances. The executor still only approved the earlier wallet-to-Permit2 ERC-20 allowances, not the later Permit2 batch spend or batch sub-allowance creation.Permit2's basic
permitTransferFromdigest binds the token, maximum amount, spender, nonce and deadline. The finaltransferDetails.torecipient is supplied by the spender at execution time. Therefore the recipient is controlled by the signed spender, not by wallet logic. This is normal Permit2 behavior, but it is a sharp edge for this wallet because ERC-1271 live signatures are treated as signer-only authorizations.permitWitnessTransferFromverifies the witness hash, but Permit2 does not interpret the witness or compare it totransferDetails.to. If the product relies on a witness to bind the user-facing recipient or order details, the spender or integration contract must enforce that the witness fields match the actual transfer details. The wallet does not do that enforcement and Permit2 does not do it generically.This does not let an outsider spend from a wallet with no valid signer signature. It also does not let the signer create the initial wallet-to-Permit2 ERC-20 approval without the executor when the normal wallet approval functions are used. The issue is the policy boundary after that approval exists: if the product expects the executor to approve all fund-moving operations, an active Permit2 allowance lets one registered signer bypass that executor review for later Permit2 transfers and Permit2 sub-allowance creation.
Cleanup is also asymmetric. Permit2
lockdown,invalidateNoncesandinvalidateUnorderedNoncesusemsg.senderas the Permit2 owner. For this smart-contract wallet, those cleanup calls must originate from the wallet, which currently means routing them through an owner-and-executor approvedexecutorExternalCall. A signer-only ERC-1271 signature can create a Permit2 sub-allowance, but signer-only cleanup of that sub-allowance is not available through Permit2.CoW Protocol has the same policy shape. The wallet first gives
GPv2VaultRelayeran ERC-20 allowance through a wallet action. That approval can require owner and executor approval. Later, a registered signer signs a CoW order digest. A live allowlisted solver callsGPv2Settlement.settle, the settlement contract asks the wallet whether the signature is valid through ERC-1271 and the wallet returns the magic value because the signer is registered. The CoW VaultRelayer then pulls the sell token from the wallet and the settlement pays the signed receiver. The executor is not asked to approve that specific CoW order, receiver, appData, partial-fill amount, expiry, or fee.This is normal CoW settlement behavior and CoW itself enforces its signed order fields. The wallet-side issue is the same as Permit2: if the product expects the executor to review every fund-moving external protocol spend, a live ERC-1271 owner signature plus a pre-existing approval is enough to bypass that executor review.
Recommendation
Decide whether live ERC-1271 signatures are allowed to authorize fund movement without the executor. If they are not, do not accept arbitrary live ERC-1271 hashes for Permit2-style spend protocols, CoW-style settlement protocols, or other approved external spenders. Require a wallet pre-authorization created through an owner-and-executor signed transaction or add a wallet policy layer that only returns the ERC-1271 magic value for hashes that were registered with the intended protocol, token, amount, spender, recipient, deadline and order metadata.
For Permit2 specifically, prefer
permitWitnessTransferFromwith a witness that binds the user-facing recipient and order details, but only through a spender/integration that enforces those witness fields against the actual transfer details. Avoid granting Permit2 reusable ERC-20 allowances unless the wallet also tracks and clears the matching Permit2 sub-allowances. Consider adding explicit wallet functions for Permit2 lockdown and nonce invalidation if Permit2 remains a supported shared spender. -
M-09 Medium Emergency blocks Permit2 cleanup and CoW cancel Unexpected Behavior Resolved
Description
executorExternalCallis the wallet's generic way to make an external protocol seemsg.sender == wallet. It always callsLibDirectMode.enforceExecutorNotBlocked(), which reverts during global pause and direct mode.This is unsafe for external protocols whose cleanup or cancellation functions are owner-scoped to the caller. Permit2 cleanup functions such as
lockdown,invalidateNoncesandinvalidateUnorderedNoncesusemsg.senderas the Permit2 owner. An outsider can call them, but that only changes the outsider's Permit2 state. To clean the wallet's Permit2 sub-allowances or nonces, the call must originate from the wallet.CoW Protocol has the same cancellation requirement.
GPv2Settlement.invalidateOrdermust be called by the order owner. For this smart-contract wallet, that means the invalidation call must come from the wallet. An outsider cannot invalidate the wallet's CoW order.During global pause, wallet-originated Permit2
lockdownand CoWinvalidateOrdercalls throughexecutorExternalCallrevert withGlobalPauseActive. During direct mode, wallet-originated Permit2 nonce invalidation and CoWinvalidateOrdercalls revert withDirectModeActive. Existing external protocol authorizations can still be consumed while these cleanup and cancellation calls are blocked. The fork tests show a Permit2 sub-allowance spending during global pause, a live ERC-1271 Permit2 transfer spending during direct mode after nonce invalidation was blocked and CoW settlement through a live allowlisted solver during both emergency states after order invalidation was blocked.Consequently, emergency states do not provide a user-protective way to revoke, invalidate or cancel those authorizations when they most need to be cleaned up.
Recommendation
Add protocol-specific emergency cleanup and cancellation functions instead of routing these calls through
executorExternalCall. They should be callable during normal operation, global pause and direct mode after a valid owner authorization. Use dedicated EIP-712 type hashes, signer-bound nonces, deadlines and the existing reentrancy guard. Do not callenforceExecutorNotBlocked()orenforceUserNotBlocked()from these functions, because they are user-protective revocations rather than executor operations.At minimum, add wrappers for Permit2
lockdown, Permit2 ordered nonce invalidation, Permit2 unordered nonce invalidation and CoWinvalidateOrder. The Permit2 wrappers should call the configured Permit2 address with zero ETH. The CoW wrapper should call the configured CoW settlement address with zero ETH and reject order UIDs that do not encodeaddress(this)as the order owner. If the product also wants immediate approval shutdown, add an owner-authorized emergency zero-approval function for known shared spenders such as Permit2 andGPv2VaultRelayer, with checks that avoid accidentally clearing a newer tracked approval unless the owner explicitly authorizes that exact(token, spender).Avoid a broadly generic emergency external call as the primary fix. If a generic mechanism is still added, restrict it to fixed protocol addresses, fixed non-fund-moving selectors, zero ETH value and calldata shape checks for each selector. A protocol-specific wrapper is safer and easier to audit.
Finally, align live ERC-1271 validation with the emergency policy. During global pause or direct mode, live signer signatures should not keep authorizing new Permit2 spends or CoW settlements while the wallet is relying on emergency cleanup. Either return invalid for live ERC-1271 signatures from known spend protocols during those states or only return the magic value for hashes that were explicitly registered through a wallet function that remains revocable in the same state.
-
M-10 Medium Recovery-admin backdoor survives signer removal Access Control Acknowledged
Description
setRecoveryConfigsets per-wallet recovery admins with a single owner signature. There is no executor co-signature, no timelock, and it takes effect immediately.validateAdminsonly rejects the zero address, duplicates, and the current factory executor, so any attacker-controlled address is accepted as a recovery admin.Separately, signer removal (
executeRemoveOwnerandexecuteRemovePasskey) only mutates the active signer arrays. It never touches recovery storage, socustomRecoveryAdminsandhasCustomConfigpersist across the removal.Together these let a signer that is compromised for even a short time install a permanent recovery-admin backdoor. While the attacker holds a registered signer, it calls
setRecoveryConfig([attacker]). This single call also replaces the wallet's existing recovery admins.Removing the compromised signer no longer contains the compromise because the attacker stays a recovery admin and can silently call
initiateRecoverythenexecuteRecoveryto add itself back and drain the wallet, while also denying the real user recovery since the legitimate admins were replaced.Recommendation
Treat a signer-set change as a revocation event for recovery state. On owner or passkey removal, reset custom recovery configuration to factory defaults, or require explicit re-confirmation of recovery admins after the signer set changes. Make
setRecoveryConfigsafer to begin with: require an executor co-signature, or a timelock plus a cancellation window, so a single momentarily-compromised signer cannot silently install a permanent recovery-admin backdoor. -
L-01 Low BatchExecutor cannot be factory executor Unexpected Behavior Acknowledged
Description
The deployment scripts and runbook direct operators to rotate
factory.executor()to the deployedBatchExecutor.DeployBatchExecutor.s.sollistsfactory.scheduleExecutorChange(batchExecutor)as the migration step and says to verify thatfactory.executor() == batchExecutor. The deployment runbook then callsScheduleExecutorChange.s.solwithNEW_EXECUTOR=$BATCH_EXECUTOR.That configuration is incompatible with wallet signature verification. Wallets call
LibAuth.verifyExecutorSignature, which checks the signature against the currentfactory.executor()throughSignatureChecker.isValidSignatureNowCalldata. This works for an EOA executor and for a contract executor that implements ERC-1271.BatchExecutordoes not implement ERC-1271isValidSignature. Therefore, once the factory executor is set toBatchExecutor, the wallet tries to validate executor signatures by staticcallingisValidSignatureonBatchExecutorand the check fails. Executor-signed operations such as allowance spends and dual-signature withdrawals then revert withInvalidExecutorSignatureuntil governance rotates the executor back to a valid signer or to an ERC-1271-capable contract.Recommendation
Keep
factory.executor()set to the backend signer or HSM-controlled EOA that actually produces executor signatures. UseBatchExecutoronly as a relay that submits pre-signed wallet calls. If the executor must be a contract, implement ERC-1271 signature validation in that contract and update the deployment scripts and docs so they distinguish the signing executor from the batch relay. -
L-02 Low Permissionless nonce invalidation censors spends DoS Resolved
Description
invalidateSpendNoncesis callable by anyone. It writes directly to the wallet's spend nonce bitmap andspendDailyAllowancerejects any spend whose nonce bit is already set.This means a third party can cancel a signed daily-allowance spend if the nonce is predictable or exposed before execution. The caller does not need an owner signature, executor signature or privileged role. The effect is censorship, not theft: funds are not redirected, but the intended executor-signed settlement fails with
SpendNonceAlreadyUsed.The protocol documentation treats permissionless invalidation as an accepted design, provided the backend monitors
SpendNoncesInvalidated, uses high-entropy nonce ranges and rotates nonces when a spend fails. The issue remains worth tracking because those safeguards are operational commitments rather than code-enforced guarantees.Recommendation
Require owner or executor authorization for nonce invalidation or replace it with a signed cancellation message. If permissionless invalidation is kept, enforce high-entropy nonce generation in the backend, avoid exposing signed spend calldata before submission and treat
SpendNonceAlreadyUsedas a mandatory nonce-rotation signal. -
L-03 Low Reused orderHash can strand old allowances Unexpected Behavior Acknowledged
Description
externalApproveAndPreAuthorizeHashdoes not reject anorderHashthat is already authorized or already has a deadline-gated approval entry. Reusing the same hash for a different(token, spender)pair overwrites the hash-keyed approval entry while leaving the first pair'scurrentApprovalOwnerslot pointing at the reused hash.When
clearStaleApprovalorrevokePreAuthorizedHashlater runs, it only sees the most recent entry. It can clear the second token approval and revoke the hash, but it no longer has the first token and spender needed to zero the original allowance. The original ERC-20 approval remains spendable even though the wallet has no usable cleanup entry for it.For example, the wallet first uses hash
Hto approveUSDeforspenderAand the cleanup entry forHpoints toUSDeandspenderA. Later, the wallet reusesHto approvetokenBforspenderB. The cleanup entry forHnow points only totokenBandspenderB. When cleanup runs forH, it clears the second approval, but the oldUSDeapproval forspenderAremains live.spenderAcan still calltransferFromagainst the wallet.This depends on a reused order hash, which should be rare if upstream protocols generate unique hashes for unique orders. The wallet still treats uniqueness as an off-chain assumption rather than an on-chain invariant.
Recommendation
Reject reused hashes before approving the token or writing new metadata. A minimal fix is to revert when
LibPreAuthorizedHash.isAuthorized(orderHash)orLibDeadlineGatedApproval.exists(orderHash)is already true. Alternatively, key cleanup entries by a wallet-generated authorization id that includes the order hash, token, spender, amount, nonce and approval deadline. -
L-04 Low Old orders use newer same-spender allowance Validation Resolved
Description
externalApproveAndPreAuthorizeHashtreats the ERC-20 allowance as one shared slot per(token, spender), but it treats pre-authorized hashes as independent approvals. When a user signs a second order for the same token and spender, the function overwrites the token allowance and records the neworderHashas the current owner of that allowance. It does not revoke the oldorderHash.Consequently, an older order can remain ERC-1271-valid after a newer authorization funds the same spender. If the external protocol still accepts the older order, the spender can consume the newer allowance even though that allowance was created for a different order.
For example, a user signs Order A for
100 USDethrough a Fusion+ router. Before Order A is filled or explicitly cancelled, the backend creates Order B for500 USDethrough the same router and callsexternalApproveAndPreAuthorizeHashagain. The wallet now gives the router a500 USDeallowance, but Order A is still ERC-1271-valid because its hash was never revoked. If the router or resolver can still fill Order A, it can pull tokens from the allowance intended for Order B. The user can therefore execute an older trade they no longer expected to be funded and the newer order's allowance can be partially consumed before Order B is filled.The contract comments state that overlapping orders for the same
(token, spender)pair are unsupported and must be avoided by the backend, but the contract does not enforce that invariant.Recommendation
When a new approval is recorded for a
(token, spender)pair, revoke or reject the active hash that currently owns that pair. A safe minimal fix is to read the previous owner beforesetCurrentOwner, revoke the previous pre-authorized hash when it is nonzero and different from the new hash and clear its approval entry. Alternatively, revert when a current owner already exists and require explicit revocation before replacement. -
L-05 Low Pending signer ready views ignore expiry Validation Partially resolved
Description
Pending signer additions and removals store both a cooldown timestamp and an expiry timestamp, but the readiness helpers only check whether the cooldown has elapsed. They do not check
expiresAtand the public pending-action getters do not return the expiry timestamp.As a result,
isPendingOwnerReadyandisPendingOwnerRemovalReadycan report an expired action as ready even though execution will revert withPendingActionExpired. The passkey readiness helpers mirror the same pattern. This can mislead wallets or operators into presenting expired signer actions as executable until another transaction prunes them.Recommendation
Include expiry in the readiness checks and expose it through the pending-action getters.
isPending*Readyshould return false whenblock.timestamp > expiresAt. UIs should also display or useexpiresAtdirectly instead of inferring readiness from cooldown alone. -
L-06 Low Direct mode cannot recover received NFTs Unexpected Behavior Resolved
Description
The wallet advertises ERC-721 and ERC-1155 receiver support and accepts those assets. Direct mode, however, only has direct ETH and ERC-20 withdrawal functions. Moving an NFT out of the wallet requires the wallet to call the NFT contract as
msg.sender, which is only possible throughexecutorExternalCall.executorExternalCallis blocked when direct mode is active. Consequently, a user who enters direct mode because the executor is unavailable or untrusted can withdraw ETH and ERC-20 balances, but cannot recover NFTs held by the wallet without leaving direct mode and using the executor-gated generic call function.Recommendation
Add direct-mode asset recovery functions for token standards the wallet can receive. A narrow fix is to add direct ERC-721 and ERC-1155 transfer functions authorized by an owner signature and gated by active direct mode.
If the product wants broader user sovereignty in direct mode, add a
directExternalCallfunction with owner-only authorization, nonce/deadline protection and careful target/value restrictions. -
L-07 Low Executor calls create untracked approvals Trust Assumptions Partially resolved
Description
executorExternalCallallows an owner and executor to authorize arbitrary calldata to any nonzero, non-self target. That includes direct ERC-20approvecalls from the wallet and approval-style calls into shared spenders such as Permit2. These approvals bypass the deadline-gated approval registry used by the dedicated approval and pre-authorization facet.The resulting allowance is real at the token contract, but
currentApprovalOwnerand the stale approval cleanup paths do not know about it because no registry entry was created.This can also corrupt later cleanup for the same
(token, spender)slot. For example, a tracked approval can expire whilecurrentApprovalOwnerstill points at its hash. If a later genericapprovecall replaces the real ERC-20 allowance without updating the registry,clearStaleApprovalwill still believe the expired hash owns the slot and will zero the newer untracked allowance.Permit2 adds a second version of the same problem.
Permit2.approve(token, spender, amount, expiration)stores a Permit2 sub-allowance keyed by(wallet, token, spender). Clearing the wallet's ERC-20 approval to Permit2 does not clear that sub-allowance. If the wallet later grants Permit2 a fresh ERC-20 allowance for another operation, the old Permit2 spender can become funded again until its Permit2 expiration passes.The missing registry entry has a further consequence once the wallet enters direct mode: an untracked approval cannot be revoked at all. Every function that can lower an allowance (
executorExternalCall,executorApproveAndExternalCall,externalApprove) begins withenforceExecutorNotBlocked()and reverts while direct mode is active. The two revoke paths that remain available in direct mode,revokePreAuthorizedHashandclearStaleApproval, only zero an allowance that has a registry entry, which anexecutorExternalCallapproval never created. So a user who enters direct mode because they no longer trust the executor can sweep the wallet's current balance but cannot cut off a standing approval the executor left to a colluding spender. Since this is a payment account that keeps receiving tokens, every inbound transfer stays pullable by that approval for as long as the user remains in direct mode; the only way to revoke is to leave direct mode and routeapprove(spender, 0)back through the executor-cosigned path, re-involving the exact party the user was escaping.Recommendation
Block ERC-20 allowance-mutating selectors and known shared-spender approval selectors in
executorExternalCallor route all approval-style calls through the deadline-gated approval facet. At minimum, the blocked selectors should includeapprove(address,uint256),increaseAllowance(address,uint256), andPermit2.approve(address,address,uint160,uint48). If the implementation uses a selector denylist, also consider legacy or allowance-reducing variants such asincreaseApproval(address,uint256),decreaseAllowance(address,uint256)anddecreaseApproval(address,uint256)so generic calls cannot desynchronize real token allowances from the wallet's registry.If generic approvals remain supported, they should also clear or replace stale ownership metadata for the same
(token, spender)slot so old tracked approvals cannot later zero newer untracked allowances.For Permit2, cleanup should also call
Permit2.approve(token, spender, 0, 0)orPermit2.lockdownfor the exact(token, spender)sub-allowance created by the wallet operation. -
L-08 Low Empty recovery reset keeps opt-out active Unexpected Behavior Resolved
Description
setRecoveryConfig([])is treated as a reset to factory default recovery admins. In the empty-array branch, the contract deletescustomRecoveryAdminsand setshasCustomConfig = false, but it does not clearrs.optedOut.LibRecovery.getEffectiveAdminschecksoptedOutbefore it checkshasCustomConfig. IfoptedOutis still true, it returns an empty admin list and never reads the factory defaults.For example, a wallet owner can opt out of recovery while traveling. Later, the UI may offer a "restore default recovery" action that calls
setRecoveryConfig([]). The transaction succeeds and the wallet no longer has a custom config, but recovery remains disabled becauseoptedOutis still true. The owner can believe factory recovery admins are active while the wallet has no effective recovery admins.This can leave recovery unintentionally disabled and increase account lockout risk.
Recommendation
Set
rs.optedOut = falsein theadmins.length == 0branch before returning. If empty arrays should not re-enable recovery, expose a separate explicit function for restoring factory defaults and make the UI/API distinction clear. -
L-09 Low BatchExecutor owner can freeze config Unexpected Behavior Resolved
Description
BatchExecutorinherits OpenZeppelinOwnable2Stepbut does not overriderenounceOwnership. The factory disables ownership renunciation, but the batch executor does not mirror that protection.If the owner renounces ownership, no address can add or remove authorized callers, update approved or blocked recipients or pause and unpause the batch executor. If renunciation happens while the contract is paused, batch execution remains permanently paused. If it happens while unpaused, stale authorized callers and recipient policy remain fixed forever.
This requires an owner action, so it is an operational issue rather than an unprivileged exploit. It is still a concrete configuration-lock risk for a security-sensitive relay component.
Recommendation
Disable ownership renunciation on
BatchExecutor, matching the factory.error RenounceOwnershipDisabled(); function renounceOwnership() public pure override { revert RenounceOwnershipDisabled(); } -
L-10 Low Upgrade script assumes EOA ProxyAdmin owner Configuration Resolved
Description
UpgradeFactory.s.solis written for aProxyAdmincontrolled directly by an EOA private key. It readsPRIVATE_KEY, checks thatadmin.owner()equalsvm.addr(ownerKey)and then broadcastsProxyAdmin.upgradeAndCallwith that key.Production ownership is intended to sit behind a
TimelockController. After theProxyAdminowner is transferred to the timelock, no private key can satisfy this script's pre-flight check or directly broadcast the upgrade call.Consequently, the production upgrade runbook and the production ownership model do not match. Operators may discover during an upgrade that the provided script cannot execute the intended timelock-owned upgrade or may be tempted to keep or restore EOA ownership just to use the script.
Recommendation
Replace the production factory upgrade script with a timelock-aware workflow. The script should deploy or reference the new implementation, prepare the
ProxyAdmin.upgradeAndCallcalldata, schedule it throughTimelockControllerand provide a separate execution step after the delay.Keep any direct-EOA upgrade script clearly marked for local or staging deployments only. Production scripts and docs should not require
PRIVATE_KEYto be theProxyAdminowner once ownership has moved to the timelock. -
L-11 Low Batch policy misses executor withdrawals Validation Partially resolved
Description
BatchExecutor.batchExecuteonly appliesapprovedRecipientsandblockedchecks when calldata targetsspendDailyAllowanceorspendDailyAllowanceEth. The same batch function accepts arbitrary wallet calldata, includingexecutorEthWithdrawalandexecutorTokenWithdrawal, but those selectors are not parsed for recipient policy.Consequently, an authorized batch caller can relay a valid owner-and-executor-signed withdrawal to a recipient that the
BatchExecutorblocklist would reject for daily allowance spends. The withdrawal still requires valid wallet signatures, so this is not an unauthenticated drain. It does mean the BatchExecutor recipient policy is not a universal compliance control for batched value transfers.Recommendation
Either restrict
batchExecuteto the supported allowance-spend selectors or extend recipient extraction and policy checks to every batched wallet function that can transfer value or grant spending rights. At minimum, document thatapprovedRecipientsandblockedonly protect daily allowance settlement calls. -
L-12 Low Dual-sign ops are not bound to executor Validation Partially resolved
Description
verifyExecutorSignaturechecksexecutorSigagainst whichever addressfactory.executor()returns when the transaction executes. The owner-signed EIP-712 digests do not name the executor and do not include a factory executor epoch.This means an owner can sign a dual-signature operation while executor E1 is active, but the operation can later be completed by executor E2 after the factory rotates executors. The owner nonce must still be unused and the signature deadline must still be valid. E2 cannot change the signed operation parameters, but it can provide the executor half of an authorization the owner created under a different executor.
The issue is broader than allowance updates. The same verification model is used by
instantSetAllowance,externalApprove,externalApproveAndPreAuthorizeHash, executor withdrawals,executorExternalCallandexecutorApproveAndExternalCall.This is not an unsigned execution issue: the wallet still requires a valid owner signature and a valid signature from the executor that is current at execution time. The risk is that a new executor can complete old owner-signed operations after rotation, even though the owner never signed a message that named that new executor.
Recommendation
Bind each dual-signature EIP-712 struct to the executor that is expected to co-sign or bind it to a factory-level
executorEpoch. If using an epoch, store it in the factory, increment it inexecuteExecutorChange, include it in every owner-and-executor digest and require the signed epoch to match the current epoch before consuming the owner nonce.Apply this to
InstantSetAllowance,ExternalApprove,ExternalApproveAndPreAuthorizeHash,ExecutorWithdrawal,ExecutorExternalCallandExecutorApproveAndExternalCall. If outstanding authorizations should survive executor rotation, document that behavior and keep deadlines short enough to limit stale owner signatures. -
L-13 Low Scheduled governance actions never expire Unexpected Behavior Resolved
Description
The factory's timelocked governance actions enforce a minimum delay but no maximum. Each execute function checks only that
block.timestampis at leastscheduledAt + UPGRADE_DELAY, with no upper bound:function executeUpgrade() external onlyOwner { if (pendingImplementation == address(0)) revert NoUpgradeScheduled(); if (block.timestamp < upgradeScheduledAt + UPGRADE_DELAY) revert UpgradeNotReady(); // ... no upper-bound / expiry check ... }The same pattern is used by:
executeUpgrade→block.timestamp >= upgradeScheduledAt + UPGRADE_DELAYexecuteExecutorChange→block.timestamp >= executorChangeScheduledAt + UPGRADE_DELAYexecuteDefaultRecoveryAdmins→block.timestamp >= recoveryConfigScheduledAt + UPGRADE_DELAYexecuteFacetUpdate→block.timestamp >= facetUpdateScheduledAt + UPGRADE_DELAY
Once the 48-hour delay has elapsed, a pending action remains executable indefinitely. If governance schedules an action, then reconsiders but does not call the matching cancel function, the stale action stays live and can be executed at any later point.
A later execution can silently apply a decision that no longer reflects current intent. For example, a beacon upgrade scheduled months earlier could be executed long after a newer implementation has superseded it; a scheduled executor change could rotate to a key that has since been retired; or a stale default-recovery-admin set could be installed after the intended roster changed. Because there is no expiry, the only safeguard against a forgotten schedule is an explicit cancel, which depends on operators remembering the pending action exists.
Recommendation
Add an expiry window to scheduled governance actions. After
scheduledAt + UPGRADE_DELAYplus a bounded grace period, require the action to be re-scheduled rather than executed or consider documenting this behavior. -
L-14 Low Executor swap can brick a pre-auth order Unexpected Behavior Resolved
Description
externalApproveAndPreAuthorizeHashrecords a pre-authorized order by setting the ERC-20 allowance for(token, spender), marking the order hash authorized inLibPreAuthorizedHash, writing a deadline-gated entry, and settingcurrentOwnerHash[token][spender]to that order hash. From then on,ERC1271Facet.isValidSignaturereturns the magic value for the order hash, and a resolver can fill the order by pulling tokens against the live allowance.executorApproveAndExternalCallperforms the following, and never reads or updates the deadline-gated approval registry or the pre-authorized hash storage:IERC20(token).forceApprove(spender, amount); // overwrites the order's allowance (success, result) = target.call{value: value}(data); if (!success) revert ExternalCallFailed(result); IERC20(token).forceApprove(spender, 0); // zeroes it after the callIf an executor swap is routed through
executorApproveAndExternalCallfor the same(token, spender)pair that a pending pre-authorized order already uses, the firstforceApproveoverwrites the order's allowance and the trailingforceApprove(spender, 0)zeroes it. The registry is never updated: the order hash is still authorized,currentOwnerHash[token][spender]still points at it, andisValidSignaturestill returns the magic value. On-chain, however, the allowance is now zero.The order is therefore bricked while still appearing valid. A resolver that queries
isValidSignaturegets a positive result and attempts the fill, but thetransferFromreverts because the allowance was zeroed by the unrelated swap. The user's standing order silently stops being fillable until it is re-approved or expires.Recommendation
Coordinate
executorApproveAndExternalCallwith the deadline-gated approval registry. Before overwriting an allowance for a(token, spender)pair, check whether a tracked order currently owns that slot and either reject the call, revoke the affected pre-authorized hash and clear its entry, or route all approval-style flows through the deadline-gated approval facet so registry state and on-chain allowance cannot diverge. At minimum, document thatexecutorApproveAndExternalCallmust not target a(token, spender)pair with an active pre-authorized order. -
L-15 Low Gas guard can still revert batches Unexpected Behavior Partially resolved
Description
BatchExecutor.batchExecutetries to stop gracefully when gas is low by checkinggasleft() <= 5000before each item. That check only looks at gas before the next external call. It does not reserve gas for the workbatchExecutemust still do after that call returns.The low-level
targets[i].call(calldatas[i])forwards almost all remaining gas to the target. If the target consumes that gas,BatchExecutorgets back only the EIP-150 reserve. That reserve may be too small to finish post-call accounting, emitBatchExecuteCompleted, ABI-encode the returnedbool[]and run thenonReentrantcleanup. In that case the transaction can still run out of gas and revert the whole batch, rolling back earlier successful items.For example:
- A relayer submits a card-settlement batch with many normal wallet spends and one unexpectedly gas-heavy wallet call near the end.
- The early spends succeed and transfer funds to merchants.
- The gas-heavy item consumes almost all forwarded gas.
- When control returns to
BatchExecutor, there is not enough gas left to record the final result, emit the completion event, return the results array and reset the reentrancy guard. - The whole transaction reverts, so the earlier merchant transfers are rolled back even though the batch logic is intended to isolate failed items or truncate before gas exhaustion.
The caller can mitigate this by supplying enough gas and avoiding unexpectedly expensive targets, so this is simply an operational reliability issue. It still matters because the contract explicitly tries to prevent low-gas batch reverts, but the current guard does not reserve enough gas to make that guarantee reliable.
Recommendation
Reserve enough gas for post-call accounting and completion before forwarding gas to each target. For example, require
gasleft()to exceed a conservative completion reserve, then call each target withgas: gasleft() - reserve. If graceful truncation is not required, remove the low-gas truncation logic and document that the relayer must provide enough gas for the full batch. -
L-16 Low Executor rotation can overlap wallet roles Validation Resolved
Description
Wallet functions prevent the current factory executor from being added to privileged wallet roles.
initiateAddOwnerandexecuteAddOwnerreject owner additions when the pending owner is the current executor.setRecoveryConfigrejects recovery admins that equal the current executor. Those checks only run when a wallet role is configured.executeExecutorChangedoes not perform the inverse check when the executor changes. After the timelock, it assignsexecutor = pendingExecutorwithout checking whether the new executor is already an owner or recovery admin for existing wallets.If the executor is rotated to an address that already owns a wallet, the same key can satisfy both the owner signature and the executor signature for that wallet. This breaks the intended separation between wallet-owner authorization and executor co-signing.
If the executor is rotated to an address that is already a recovery admin, the same address can initiate recovery for that wallet. This breaks the intended separation between executor co-signing authority and recovery authority.
This requires an executor rotation by the factory owner. The issue still matters because that rotation can silently invalidate role-separation assumptions that existing wallets already relied on.
Recommendation
Treat executor rotation as a role-conflict change. Reject scheduled executor changes when the new executor is known to already be a wallet owner or recovery admin.
Because the factory may not know every per-wallet role assignment, also enforce the separation where the roles are consumed. Dual-signature wallet operations should reject address-based owner signers that resolve to the current factory executor.
RecoveryFacet.initiateRecoveryshould rejectmsg.sender == factory.executor().Also validate default recovery admin changes against the current executor and any pending executor. If feasible, maintain an indexed registry of wallet owners and recovery admins so the factory can block known conflicts before
executeExecutorChangeupdates the executor. -
L-17 Low externalApprove silently bricks live orders Logical Error Resolved
Description
A pre-authorized order and a plain token approval end up competing for the exact same on-chain state, and
externalApproveresolves that conflict by silently clobbering whatever was there before.When
externalApproveAndPreAuthorizeHashsets up an order, it does three things for a given(token, spender):- sets the ERC-20 allowance via
forceApprove(spender, amount), - flags the
orderHashso thatisValidSignature(orderHash)answers with the magic value, - records that
orderHashas the owner of the allowance slot (setCurrentOwner(token, spender, orderHash)).
A later call to
externalApproveon the same pair sets its own allowance and stamps its ownapproveHashas the new owner, but it does not look at who owned the slot first, and it does not turn off the earlier order's pre-authorization flag. The earlier order therefore keeps reporting itself as a valid signature even though the allowance sitting behind it has been replaced.externalApprovealso never checks that the approved amount is nonzero; it only guards against a zerotoken, a zerospender, and out-of-range deadlines before forwarding the signedamountstraight intoforceApprove:if (token == address(0)) revert ZeroAddress(); if (spender == address(0)) revert ZeroAddress(); if (approvalDeadline <= block.timestamp) revert InvalidApprovalDeadlinePast(); if (approvalDeadline > type(uint64).max) revert InvalidApprovalDeadlineTooFar(); // ... no `amount != 0` check ... IERC20(token).forceApprove(spender, amount);So a single approval call to a pair that already hosts a live order can either shrink, grow, or completely remove that order's funding while the order still looks fillable from the outside.
Recommendation
Before
externalApproveoverwrites the allowance for a(token, spender)pair, it should hand off the slot cleanly instead of silently replacing it. - sets the ERC-20 allowance via
-
L-18 Low Silent batch truncation is under-observable Events Resolved
Description
batchExecutetries to stop gracefully when gas runs low by breaking out of the loop before the next item:for (uint256 i = 0; i < len;) { if (gasleft() <= 5000) break; // silent early exit attempted = i + 1; ... (bool success,) = targets[i].call(calldatas[i]); ... } emit BatchExecuteCompleted(batchId, attempted, succeeded);When the gas guard fires, the loop breaks and the function still completes "successfully." The only signal emitted is
BatchExecuteCompleted(batchId, attempted, succeeded), whereattempted = i + 1is the number of items processed before the break, not the requestedtargets.length. The event does not carry the requested batch size at all.Recommendation
Make truncation observable on-chain. Emit the requested batch size (
targets.length) alongsideattemptedinBatchExecuteCompleted, or emit a distinct event (e.g.BatchTruncated(batchId, attempted, requested)). -
L-19 Low Cancel signature not bound to its recovery Signatures Resolved
Description
A
cancelRecoverysignature should cancel one specific recovery. It doesn't, it can cancel a different one.The signed message includes
pendingInitiatedAt(the recovery's timestamp) but notpendingNewSigner(which recovery it is). So the only thing tying the signature to a recovery is its timestamp. Two recoveries created in the same block share the same timestamp, so a signature for the first one also works on the second.This is reachable because
setRecoveryConfig(andoptOutRecovery) can cancel a pending recovery without using the cancel signer's nonce. So:- Owner A signs a cancel for recovery A but doesn't broadcast it.
- Owner B calls
setRecoveryConfig, which cancels recovery A. Owner A's nonce is untouched. - A recovery admin starts recovery B in the same block, it gets the same timestamp as A.
- Owner A's old signature now cancels recovery B instead.
Recommendation
Bind the recovery target into the cancel signature so it only works on the recovery it was meant for. Either:
- Add
pendingNewSigner(hashed) toCANCEL_RECOVERY_TYPEHASHand intohashCancelRecovery, or - Add a per-recovery id that increments on each
initiateRecoveryand bind it into the cancel signature.
Otherwise, acknowledge and document the behavior.
-
L-20 Low Pending passkey removal view returns stale index Validation Acknowledged
Description
PendingPasskeyRemovalstores apasskeyIndexthat is captured when the removal is initiated ininitiateRemovePasskeyand is never updated afterward:aus.pendingPasskeyRemovals[actionId] = PendingPasskeyRemoval({ passkeyIndex: index, // captured here, never refreshed qx: _qx, qy: _qy, ... });The active
passkeysarray is maintained with swap-and-pop, so removing any other passkey moves the last element into the removed slot and changes the indices of existing entries:aus.passkeys[indexToRemove] = aus.passkeys[passkeyLen - 1]; aus.passkeys.pop();After such a reordering, the
passkeyIndexrecorded in a still-pending removal no longer points to the passkey it was created for, and may point to an unrelated passkey or be out of bounds.getPendingPasskeyRemovalreturns this stale index to off-chain callers, so any consumer that trustspasskeyIndexto identify the passkey scheduled for removal can act on the wrong entry, for example displaying or operating on a different passkey than the user intended to remove.Recommendation
Do not expose a cached array index that can drift. Either remove
passkeyIndexfromPendingPasskeyRemovaland the getter and rely solely onqxandqyfor identity, or recompute the current index on read by scanning for the stored coordinates. If the index is kept for convenience, document that it is only valid at initiation time and must not be used after any other passkey removal. -
I-01 Informational Default daily limits allow duplicate arrays Validation Partially resolved
Description
setDefaultDailyLimitschecks only that token and amount arrays have equal length and that each amount is below the maximum. It does not cap array length and does not reject duplicate token entries. New wallet initialization loops over the full configured array and applies each entry in order, so duplicate tokens are silently overwritten by later entries.This is owner-controlled and only affects future wallet deployments, but it creates avoidable operational risk and gas griefing against new wallet creation.
Recommendation
Add a conservative maximum length for default daily limit arrays and reject duplicate token addresses. Emit or store the canonical deduplicated configuration so off-chain tooling and wallet initialization agree on the effective defaults.
-
I-02 Informational WebAuthn RP ID hash is not enforced on-chain Warning Partially resolved
Description
LibAuth._verifyWebAuthnaccepts a WebAuthn assertion afterWebAuthn.verifyconfirms the challenge, user presence, optional user verification and P-256 signature. The wallet does not compare the first 32 bytes ofauthenticatorDataagainst an expected RP ID hash.Consequently, the on-chain verifier relies on frontend and platform controls to keep passkey assertions scoped to the intended relying party. If those controls fail, an assertion signed by a registered passkey can still verify on-chain as long as the challenge matches. Nonces and deadlines still apply, so this is a domain-separation hardening issue rather than an unauthenticated signing bypass.
Recommendation
Store or configure the expected RP ID hash and compare it to
bytes32(auth.authenticatorData[0:32])before accepting a WebAuthn assertion. Keep frontend origin validation as defense in depth, but do not make it the only relying-party check for wallet authorization. -
I-03 Informational Batch accepts non-wallet call targets Warning Acknowledged
Description
BatchExecutor.batchExecuteis documented as executing calldata on wallet proxy targets, but it does not enforce that eachtargetis a wallet proxy or even a deployed contract. After checking only thatmsg.senderis an authorized caller and that the arrays line up, it executes each item withtargets[i].call(calldatas[i]).This gives authorized callers the ability to make arbitrary calls from the
BatchExecutoraddress. For example, if ERC-20 tokens are accidentally sent to the batch executor, an authorized caller can include the token contract as the batch target and calltransfer, moving those tokens asmsg.sender == BatchExecutor. The same missing target validation also means a call to an EOA or wrong address returns low-level success and is counted as a successful batch item even though no wallet operation executed.Recommendation
Validate batch targets before forwarding calls. If
BatchExecutoris only meant to relay wallet operations, require each target to be a known wallet proxy from the factory or at minimum requiretarget.code.length > 0and reject non-wallet targets. Also consider emitting per-item target and result data so off-chain settlement can detect invalid target submissions. -
I-04 Informational Policy reverts abort entire batch Warning Acknowledged
Description
BatchExecutor.batchExecutestates that one failed item should not abort the batch and the low-level wallet calls are recorded in theresultsbitmap. However, the recipient policy checks forspendDailyAllowanceandspendDailyAllowanceEthhappen before the low-level call and use directrevertstatements when the recipient is blocked or not approved.As a result, a single blocked or unapproved recipient aborts the entire transaction and rolls back earlier successful items. For example, a batch with a valid spend to an approved merchant followed by a spend to a newly blocked merchant reverts as soon as the blocked item is checked. The valid first spend is rolled back and no
BatchExecuteCompletedevent is emitted for the partial outcome.Recommendation
Keep recipient-policy failures inside the per-item result path. Instead of reverting on
RecipientBlockedorRecipientNotApproved, mark that item as failed, continue processing the rest of the batch and emit enough per-item information for off-chain systems to identify the rejected recipient. If policy failures are intentionally meant to abort the whole batch, update the function documentation and monitoring expectations to make that behavior explicit. -
I-05 Informational Recovery admins cannot replace pending recovery Warning Acknowledged
Description
initiateRecoveryrejects every new recovery whilependingActiveis true. The only cancellation function iscancelRecovery, and it requires a valid owner signature. Recovery admins therefore cannot cancel or replace a pending recovery, even when they are still current admins for the wallet.This gives any recovery admin control over the single pending recovery slot once they call
initiateRecovery. If that admin queues the wrong signer, whether by mistake or compromise, the rest of the recovery admin set cannot correct it. If the wallet owner cannot signcancelRecovery, the wallet is left with two practical choices: execute the unintended pending recovery after the 10-day delay or leave recovery blocked.For example, a user loses access to their only working signer and asks two configured recovery admins to help restore the wallet. The first admin enters the wrong address and initiates recovery to that signer. The second admin notices the problem before the delay expires and tries to start recovery to the correct replacement signer, but
initiateRecoveryreverts withRecoveryAlreadyPending. After the 10-day delay, the same restriction still prevents replacement. Anyone can instead execute the queued recovery and add the unintended signer as an owner.This matters most when recovery is actually needed because the user has lost access to existing signers. In that situation the owner-signed cancellation control is unavailable, so a bad pending recovery can prevent the configured recovery admins from restoring the wallet to the intended signer.
Recommendation
Keep owner-signed
cancelRecoveryas the highest-authority cancellation control, but add a recovery-admin fallback for cases where no owner signature is available. A safe design should store enough pending-recovery metadata to authorize correction, emit an explicit cancellation or replacement event, and prevent silent overwrites.For example, store the pending initiator and allow the initiating admin or a quorum of current recovery admins, to cancel or replace an active pending recovery. Also consider adding an expiry so a pending recovery cannot block future recovery forever when no owner signature is available.
-
I-06 Informational External approval docs omit approvalDeadline Documentation Acknowledged
Description
Several operator-facing docs describe
externalApproveandexternalApproveAndPreAuthorizeHashwithout theapprovalDeadlinefield. The current contracts includeapprovalDeadlinein both the external function ABI and the EIP-712 type hashes:ExternalApproveFacethashesExternalApprove(bytes32 signer,address token,address spender,uint256 amount,uint256 approvalDeadline,uint256 nonce,uint256 deadline)andExternalApproveAndPreAuthorizeHashFacetlikewise hashesapprovalDeadlinebeforenonceanddeadline.The stale ABI and typed-data descriptions appear in
API.md,SECURITY.md,MIGRATION.mdandUSER_GUIDE_VERIFY_BEFORE_SIGNING.md.MIGRATION.mdis especially misleading because it says the breaking typehash change was only the addition ofbytes32 signer, while the deployed approval payloads also require the cleanup deadline.The approval events are also inconsistent.
API.mdomitsapproveHashandapprovalDeadlinefromExternalApprovalandMONITORING.mdlistsExternalApprovalAndHashAuthorizationwithapprovalDeadlinebefore the hash. The contract event order istoken,spender,amount,orderHash,approvalDeadline.Recommendation
Update every
ExternalApproveandExternalApproveAndPreAuthorizeHashexample to includeapprovalDeadlinein the same position as the contracts. The migration checklist should explicitly call out both breaking changes: the addedsignerfield and the added approval cleanup deadline. Also update the event examples to match the compiled ABI, includingapproveHashforExternalApprovaland the correctorderHash,approvalDeadlineorder forExternalApprovalAndHashAuthorization. -
I-07 Informational Query guides use nonexistent view calls Documentation Acknowledged
Description
OPERATOR_QUERY_GUIDE.mdsays everycast callexample uses the exact signature, but several examples target functions that are not in the current wallet or factory ABI. The guide tells operators to callcurrentDayStart,isInDirectMode,directModeActivatesAt,exitDirectModeActivatesAt, factory-levelgetRecoveryConfig(address),isRecoveryOptedOut,getActiveRecovery,pendingOwnerCountand factoryisGloballyPaused.USER_GUIDE_VERIFY_BEFORE_SIGNING.mdrepeats the stale direct-mode and global-pause calls and also queries recovery config on the factory.The current contracts expose different views.
DirectModeFacetexposesisDirectMode(),pendingDirectMode()andpendingExitDirectMode().UserProxyFactoryBeaconexposes the publicglobalPaused()getter.RecoveryFacetexposes wallet-levelgetRecoveryConfig()andgetPendingRecoveryStatus().AuthFacetexposespendingOwnerAdditionCount(), notpendingOwnerCount(). No publiccurrentDayStart()view exists.Operators following the docs therefore get selector-not-found reverts or failed factory calls when checking pause state, direct-mode state, recovery status, allowance reset state and pending signer operations. This weakens the intended pre-signing and operational safety checks because the documented checks cannot be executed as written.
Recommendation
Replace the stale examples with the current ABI. Use
isDirectMode()(bool),pendingDirectMode()(uint64,bool),pendingExitDirectMode()(uint64,bool),globalPaused()(bool), wallet-levelgetRecoveryConfig()(address[],uint256,bool,bool),getPendingRecoveryStatus()(bool,bytes,uint256,uint256)andpendingOwnerAdditionCount()(uint256). RemovecurrentDayStart. -
I-08 Informational Runbook uses bytes16 wallet UUID selectors Documentation Acknowledged
Description
The deployment runbook's wallet deployment examples call
predictWalletAddress(bytes16)anddeployWalletWithPasskey(bytes16,bytes32,bytes32). The factory ABI usesbytes32 walletUuidfor address prediction and wallet deployment.Following the runbook literally produces calldata for selectors that the factory does not implement. Other docs already use
bytes32, so this is an isolated but copy-pasteable ABI drift in the main deployment runbook.Recommendation
Change the deployment runbook examples to
predictWalletAddress(bytes32)(address)anddeployWalletWithPasskey(bytes32,bytes32,bytes32). If backend UUIDs are naturally 16 bytes, document the exact expansion rule to thebytes32 walletUuidused as the CREATE2 salt before calling the factory. -
I-09 Informational Batch docs use removed event name Documentation Acknowledged
Description
The user guide tells users to look up
BatchSpendExecuted(uint256,uint256,uint256)after a batch andAPI.mddocuments the same removed event name.PRODUCT.mdalso saysBatchSpendExecutedis emitted after batch settlement. The currentBatchExecutordefines and emitsBatchExecuteCompleted(uint256 indexed batchId, uint256 total, uint256 succeeded).A user, monitor or indexer built from these docs will not find batch execution logs.
Recommendation
Replace
BatchSpendExecutedwithBatchExecuteCompletedin the user guide, API reference and product flow. Keep the monitoring guide as the source of truth for alerting fields and consider adding a small ABI-check test that validates documented event names against compiled ABIs. -
I-10 Informational Threat model overstates direct-mode caller risk Documentation Acknowledged
Description
The BatchExecutor selector-space analysis contradicts the current wallet authorization model. The direct-mode table correctly lists
initiateDirectMode,cancelDirectMode,initiateExitDirectModeandcancelExitDirectModeas "Owner sig only". A later section then places three of those same functions under "Selectors a CompromisedauthorizedCallerCan Route Without Owner Co-Signature" and says the call will succeed throughbatchExecutesubject only to direct-mode state preconditions.The contract does not behave that way. Each direct-mode control function takes
signer,nonce,deadlineandownerSig, then callsLibAuth.verifyOwnerSignature, checks that the recovered signer matchessignerand consumes the signer nonce before changing direct-mode state. A compromisedBatchExecutorcaller can submit calldata to a wallet, but the wallet still rejects these direct-mode calls unless the calldata includes a valid registered-owner signature.Consequently, the documented compromised-caller scenario is too broad. A leaked
authorizedCallercannot systematically start direct mode, cancel a pending direct-mode entry, or force a direct-mode exit across wallets by itself. Those actions require wallet-owner authorization. The real caller-only analysis should focus on selectors that truly do not require owner authorization, such asexecuteRecovery()after the recovery delay and on selectors with different non-owner controls, such as executor-signed allowance spends.The same selector inventory also contains stale ABI strings. For example,
executorApproveAndExternalCallis missing thetargetandvaluearguments, both external approval signatures omitapprovalDeadlineandrevokePreAuthorizedHashomits thehashargument. This makes the table unreliable for threat modeling or automated selector checks.Recommendation
Update
THREAT_MODEL.mdso it describes the current code instead of treating every selector reachable throughbatchExecuteas successful without owner authorization. In the selector table, keepinitiateDirectMode,cancelDirectMode,initiateExitDirectModeandcancelExitDirectModemarked asOwner sig only. In the compromised-authorizedCallersection, remove those direct-mode functions from the "no owner signature requirement" list and add a note thatbatchExecutecan route the calldata, but the wallet will revert unless the calldata includes a valid registered-owner signature.Keep
executeRecovery()in the no-owner-signature list only with its recovery-delay precondition. ForspendDailyAllowanceandspendDailyAllowanceEth, state that a compromised caller also needs valid executor-signed spend calldata and must pass theapprovedRecipientspolicy. Finally, regenerate or manually correct the stale ABI strings forinstantSetAllowance,executorApproveAndExternalCall,externalApprove,externalApproveAndPreAuthorizeHashandrevokePreAuthorizedHashso the selector inventory matches the compiled contracts. -
I-11 Informational Overloaded DailyLimitExceeded revert error Best Practices Acknowledged
Description
spendDailyAllowancereuses a single error,DailyLimitExceeded, for two semantically distinct failure conditions:function spendDailyAllowance(address token, uint256 amount) internal { AllowanceStorage storage als = allowanceStorage(); uint256 limit = als.dailyLimits[token]; if (limit == 0) revert DailyLimitExceeded(); // token never configured / blocked ... if (spending.amountSpent + amount > limit) revert DailyLimitExceeded(); // cap actually exceeded }The first branch fires when the token has no daily limit set, where
0is the default-blocked state (the executor cannot spend until the owner sets a limit viainstantSetAllowance). The second branch fires when a configured limit is genuinely exceeded. Both revert with the same selector, so the two conditions are indistinguishable to any caller.Recommendation
Introduce a distinct error for the unconfigured/blocked case, for example
DailyLimitNotSet(address token)(orTokenSpendingBlocked), and revert with it in thelimit == 0branch, reservingDailyLimitExceededfor the case where a configured limit is actually surpassed. This lets off-chain systems and integrators react correctly to each condition and aligns the spend path with the state distinction the view functions already expose. -
I-12 Informational Passkey signer docs use qx instead of key hash Documentation Acknowledged
Description
The EIP-712 reference tells integrators to encode a passkey signer as
qx, the P256 public key X coordinate. The wallet does not useqxas the passkey signer key.LibAuth._passkeyKeyderives the key askeccak256(abi.encode(qx, qy))andAuthFacetrejects every signed operation when the verified signer key does not equal thesignerfield in calldata.The same incorrect guidance appears in the user and operator guides. An integration that follows those docs will query
nonce(bytes32)with the wrong key, build typed data withsigner = qxand produce signatures that revert withSignerMismatch. This affects passkey-signed signer management and every other owner-signed wallet operation that uses the sharedsignerfield.Consequently, wallets, SDKs or emergency runbooks built from the documentation cannot execute passkey-authorized actions when they are needed.
Recommendation
Update every signer-key reference to state that passkey signer keys are
keccak256(abi.encode(qx, qy)). Keep the owner encoding asbytes32(uint256(uint160(ownerAddress))).Consider exposing a small view helper that returns the signer key for a stored passkey or for supplied
(qx, qy)coordinates, so off-chain tooling does not need to duplicate the encoding rule. -
I-13 Informational Docs claim delay is signed in config Documentation Acknowledged
Description
The EIP-712 reference says the
SetRecoveryConfigconfigHashis the hash of the "admins list + delay". The current wallet does not sign the recovery delay in this message.RecoveryFacet.setRecoveryConfigcomputesconfigHashaskeccak256(abi.encode(admins)), then signs that hash together withsigner,nonceanddeadlinethroughLibRecovery.hashSetRecoveryConfig.The recovery delay is not configurable per wallet. It is the fixed
LibRecovery.RECOVERY_DELAYconstant, returned bygetRecoveryConfig()and enforced whenexecuteRecovery()checks the pending recovery timestamp. Therefore, this is not a runtime authorization bypass in the current code.Essentially, the signing reference describes a value that the wallet never verifies. A client built from the docs may display a recovery-delay value as part of the signed recovery config, even though the signature only commits to the admin list. A client that actually signs
keccak256(abi.encode(admins, delay))will produce a signature the wallet rejects.Recommendation
Update the EIP-712 reference to state that
configHash = keccak256(abi.encode(admins)). Add an explicit note that the 10-day recovery delay is the fixedLibRecovery.RECOVERY_DELAYcontract constant and is not part of theSetRecoveryConfigsigned payload. If a future upgrade makes the delay configurable, update both the typed-data spec and the on-chain hashing code to include the exact delay value. -
I-14 Informational Parent preauth can authorize child ops Unexpected Behavior Acknowledged
Description
ERC1271Facet.isValidSignaturereturns the ERC-1271 magic value for any hash stored inLibPreAuthorizedHash, even when the supplied signature is empty.externalApproveAndPreAuthorizeHashcan store any nonzeroorderHashand that hash is treated as an opaque external order hash. The stored value is not tagged with a purpose, an expected verifier or the wallet operation it is allowed to authorize.This becomes unsafe when one Ethenapay wallet is registered as an EIP-1271 owner of another wallet. The child wallet's
LibAuth._verifyEIP1271checks that the parent wallet address is one of its owners, then asks the parent wallet whether the child operation digest is a valid signature. If the parent wallet has preauthorized that same digest as anorderHash, the parent returns the ERC-1271 magic value. The child wallet then treats the parent response as owner approval for its own operation.For example:
- A user or business uses a parent wallet as the smart-contract owner of a child wallet that holds operational funds.
- An integration asks the parent wallet to preauthorize an opaque
orderHashthroughexternalApproveAndPreAuthorizeHash. - The opaque hash is chosen to equal the EIP-712 digest of a child-wallet operation, such as
executorEthWithdrawalto a recipient controlled by the integrator or attacker. - The parent signer and executor authorize the preauthorization transaction, believing it is an external order authorization for the parent wallet.
- Later, the child wallet receives an EIP-1271 owner signature that contains only the parent wallet address and no live contract signature.
- The child calls
isValidSignatureon the parent wallet. Because the digest was preauthorized, the parent returns the magic value and the child accepts it as owner approval for the withdrawal.
This requires the child wallet to use the parent wallet as an EIP-1271 owner. It also requires the parent signer and executor to authorize the chosen hash, plus the child executor signature for the child operation. The issue is that the parent preauthorization is not limited to the external protocol context shown to the parent signer. The same hash can be reused as authorization for a different wallet's internal operation.
Recommendation
Separate external-order preauthorizations from EIP-1271 owner signatures used by wallet-to-wallet authentication. Store enough context with each preauthorized hash to restrict who may consume it, such as an authorization purpose and expected verifier. Then make
isValidSignaturereturn the magic value for a preauthorized hash only when the caller matches that context.If wallet proxies are allowed to be owners of other wallets, also require a non-empty live contract signature when
LibAuth._verifyEIP1271verifies an Ethenapay wallet owner. A preauthorized hash on the owner wallet should not by itself satisfy authorization for a different wallet's internal operation. -
I-15 Informational Recovery bypasses signer count caps DoS Acknowledged
Description
Normal signer additions enforce
MAX_PASSKEYSandMAX_OWNERSso the wallet's linear auth scans stay bounded. The recovery direct-add helpers skip those caps.addPasskeyDirectpushes a passkey regardless of the current passkey count andaddOwnerDirectpushes an owner regardless of the current owner count.As a result, a recovery execution can take a wallet above the advertised 10-passkey or 10-owner limits. Repeated recoveries can keep growing the signer arrays over time. This makes the gas cost of
isOwner,hasPasskey, signer removal and WebAuthn verification scale beyond the bounds assumed elsewhere inLibAuth.The issue requires a recovery admin and the 10-day recovery delay, so it is not an immediate public denial of service. It is still a code-level exception to the signer-count invariant. A wallet that accumulates many recovery-added signers can become more expensive or unreliable to operate, especially for WebAuthn signatures that may test each stored passkey before finding the matching key.
Recommendation
Keep recovery within a bounded signer model. If a wallet is already at the signer cap, recovery should replace an existing signer, require an explicit signer-removal step, or use a separate bounded emergency slot that is promoted only after a stale signer is removed.
If bypassing the caps remains intentional, update the invariants and user-facing limits to state that recovery can exceed them. Add an operational cap or monitoring alert so repeated recovery executions cannot silently grow signer arrays until normal auth operations become too expensive.
-
I-16 Informational Upgrade omits approval view selectors Configuration Resolved
Description
AddPreAuthorizeFacetschedules the new preauthorization facet with onlyexternalApproveAndPreAuthorizeHash,revokePreAuthorizedHashandclearStaleApproval. It also repointsisValidSignature, but it does not registercurrentApprovalOwnerorapprovalEntry.Fresh deployments include those two view selectors in
DeployBeaconFactory, so this only affects factories upgraded with the example post-deploy script. After such an upgrade, the wallet can still create and clear deadline-gated approvals, but off-chain monitors, keepers and operators cannot query the current allowance owner or the stored approval entry through the wallet proxy. Calls to those selectors revert withFunctionNotFound.This does not directly move funds. The risk is weaker stale-approval observability after an upgrade. Cleanup automation that depends on the view functions may fail to discover or verify expired approvals.
Recommendation
Register
ExternalApproveAndPreAuthorizeHashFacet.currentApprovalOwner.selectorandExternalApproveAndPreAuthorizeHashFacet.approvalEntry.selectorin the post-deploy upgrade script. Update the selector array length and the AddPreAuthorizeFacet tests so the legacy-upgrade scenario asserts that both view selectors route after the cut. -
I-17 Informational executeRecovery breaks reentrancy invariant Informational Resolved
Description
Every other externally-callable, state-mutating facet function brackets its body with
LibReentrancyGuard.enforceNonReentrant()/clearReentrantFlag()(withdrawals, allowance set/spend, external calls, approvals, all Auth initiate/approve/cancel, all Recovery owner-ops, all DirectMode ops). The only exceptions areAllowanceFacet.invalidateSpendNonces(intentionally permissionless and purely protective) andexecuteRecovery, which mutates the signer set (addOwnerDirect/addPasskeyDirect) but carries no guard.Recommendation
Add
enforceNonReentrant()at entry andclearReentrantFlag()on every exit path (including the earlyreturn). -
I-18 Informational Executor wallet preauth satisfies executor sig Warning Resolved
Description
verifyExecutorSignatureaccepts the current factory executor through OpenZeppelinSignatureChecker. If the factory executor is an Ethenapay wallet proxy, the executor check calls that wallet'sERC1271Facet.isValidSignature.The preauthorized-hash behavior in
ERC1271Facet.isValidSignatureis intentional. The repository documents preauthorized hashes as values the wallet will accept whenisValidSignatureis called.LibPreAuthorizedHashalso says the feature exists so the wallet can return ERC-1271 magic for a specific hash without requiring a fresh signature at verification time. The intended example is a 1inch Fusion+ style order: Alice signs one wallet authorization, the wallet approves the router, the wallet stores the externalorderHashand a resolver later callswallet.isValidSignature(orderHash, "").The warning is that the same ERC-1271 answer can be consumed by a different authorization role. For example, Alice owns the target wallet. Mallory is only the recipient and transaction submitter. The executor is the protocol co-signer that should provide the second approval for
executorEthWithdrawal. A separate Ethenapay wallet is later rotated into the global executor role. Before that rotation, this executor wallet stores Alice's withdrawal digest as a preauthorizedorderHash.After the rotation, Alice's wallet checks whether the current executor approved the withdrawal. Since the executor is now a wallet proxy,
SignatureCheckercallsexecutorWallet.isValidSignature(withdrawalDigest, ""). The executor wallet returns ERC-1271 magic because that digest is already stored inLibPreAuthorizedHash. Alice's wallet then accepts the emptyexecutorSigand executes the withdrawal because Alice's owner signature is valid.This does not show that Mallory can steal funds without Alice. Alice still signs the exact withdrawal parameters, including recipient, amount, nonce and deadline. Mallory cannot change those values. The concern is limited to the executor-signature check: a generic preauthorized hash on a contract executor can satisfy that check for another wallet operation.
Recommendation
Document the intended scope of preauthorized hashes. If a preauthorized hash is meant to be a valid ERC-1271 signature for every caller and purpose, document that executor-wallet configurations inherit that behavior.
If executor approval is meant to remain a separate authorization layer, do not let generic wallet ERC-1271 preauthorizations satisfy executor signatures. Require a dedicated executor-verification interface or an ERC-1271 signature mode that cannot return success from
LibPreAuthorizedHash.At minimum, reject Ethenapay wallet proxies as factory executors unless they implement a separate executor-only validation facet. A stronger fix is to bind each preauthorized hash to an explicit purpose and verifier, then make
ERC1271Facet.isValidSignaturereturn the magic value only whenmsg.sendermatches the stored verifier and the stored purpose is valid for that caller. -
I-19 Informational BatchExecutor deploy ignores initial owner Documentation Partially resolved
Description
DeployBatchExecutor.s.solalways deploys the executor withdeployeras the constructorinitialOwner. The script never reads theINITIAL_OWNERvalue shown in the replacement guide.Consequently, an operator following the guide can believe the new
BatchExecutoris owned by the multisig while the deployer EOA actually controlsaddCaller,removeCaller,addRecipient,removeRecipient,addBlocked,removeBlocked,pauseandunpause. The same script also auto-registers the deployer as an authorized caller. If the handoff is missed during a replacement, a hot deployer key keeps both administrative control and batch-submission authority over the new executor.The main deployment guide later includes separate ownership-transfer steps and the validation scripts can catch the wrong owner if they are run with the correct expected owner. The replacement guide does not include that handoff, so the script behavior is still a concrete operational security risk for BatchExecutor replacements.
Recommendation
Read an explicit owner from the environment and pass that value into the constructor or remove
INITIAL_OWNERfrom the guide and make the two-step BatchExecutor ownership transfer a required step in every replacement runbook.If the owner is not the deployer, split deployment from initial configuration. The owner should perform caller, recipient and blocklist setup or the script should clearly stop after deployment and instruct the owner multisig to execute the configuration transactions.
-
I-20 Informational Recovery reset event misreports admin set Best Practices Resolved
Description
setRecoveryConfigtreats an emptyadminsarray as "reset to factory defaults." In that branch it deletes the custom admins, setshasCustomConfig = false, and emits:// admins.length == 0 branch delete rs.customRecoveryAdmins; rs.hasCustomConfig = false; emit RecoveryConfigChanged(address(this), 0, configHash); // configHash = keccak256(abi.encode(admins)) = hash of []The event reports
adminCount = 0andconfigHash = keccak256(abi.encode([])). But after this call the wallet's effective recovery admin set is the factory defaults, not an empty set.LibRecovery.getEffectiveAdminsreturnsfactory.getDefaultRecoveryAdmins()wheneverhasCustomConfig == falseand the wallet is not opted out:if (rs.optedOut) return new address[](0); if (rs.hasCustomConfig) return rs.customRecoveryAdmins; return IUserProxyFactory(factory).getDefaultRecoveryAdmins(); // <-- effective set after a resetSo the on-chain getter (
getRecoveryConfig) correctly returns the factory defaults, but the emitted event tells a different story.Recommendation
Make the event reflect the effective configuration after a reset. Either emit the factory default admin count and a hash of the effective set in the reset branch, or add a distinct event (e.g.
RecoveryConfigResetToDefaults(wallet)).
Remediation Review
28 findings · June 26 to 29, 2026-
M-01 Medium Authorized deployer seizes funded UUID wallets Unexpected Behavior Acknowledged
Description
Wallet addresses are derived only from
walletUuidand the beacon proxy init code. The initial signer is not part of the CREATE2 salt. Instead, an authorized deployer supplies the first passkey or owner whendeployWalletWithPasskey,deployWalletWithOwnerordeployWalletWithBothis called.Here, "authorized deployer" does not mean any arbitrary address. It means the factory
owner()or an address withisDeployer[account] == true, because the deployment functions are guarded byonlyDeployer. The factory also grants deployer status to the initialexecutorduring initialization, so that executor key is custody-critical for undeployed wallets that may already hold funds.Consequently, an authorized deployer that knows a funded
walletUuidcan deploy that wallet with attacker-controlled signer data. Any assets already sent to the predicted address then become controlled by the deployer-selected signer. This breaks the non-custodial assumption for undeployed counterfactual wallets that users or integrators can pre-fund before initialization.This is especially sensitive because the factory grants deployer status to the initial
executorduring initialization.For example:
- A user or integrator computes the wallet address from
walletUuid. - They send ETH or tokens to that predicted address before deployment.
- An authorized deployer learns that
walletUuid. - Instead of deploying with the real user's signer, the deployer calls
deployWalletWithOwner(walletUuid, attackerOwner). - The wallet is deployed at the already-funded address.
WalletDiamond.initializeWithOwner()registersattackerOwneras the wallet owner:
function initializeWithOwner(address owner) external { if (_isInitialized()) revert AlreadyInitialized(); _setInitialized(); LibFactory.setFactory(msg.sender); LibAuth.initializeWithOwner(owner); _applyDefaultAllowances(); emit WalletInitialized(msg.sender); emit OwnerAdded(owner); }- The real user cannot redeploy the wallet with their own signer because the UUID is now used and
walletProxies[walletUuid]is set. - The attacker-controlled signer can enter direct mode and withdraw the funds. Direct mode is signer-authorized by
initiateDirectMode:
function initiateDirectMode(bytes32 signer, uint256 nonce, uint256 deadline, bytes calldata ownerSig) external { LibReentrancyGuard.enforceNonReentrant(); if (block.timestamp > deadline) revert DirectModeExpired(); bytes32 structHash = LibDirectMode.hashInitiateDirectMode(signer, nonce, deadline); bytes32 digest = LibEIP712.toTypedDataHash(structHash); bytes32 signerKey = LibAuth.verifyOwnerSignature(digest, ownerSig); if (signerKey != signer) revert LibAuth.SignerMismatch(signer, signerKey); LibAuth._useCheckedNonce(signerKey, nonce); LibAuth.enforceNotPendingRemoval(signerKey); uint64 activatesAt = LibDirectMode.initiateDirectMode(); emit DirectModeInitiated(activatesAt); LibReentrancyGuard.clearReentrantFlag(); }The relevant
directEthWithdrawallogic then verifies the same signer model and sends ETH:function directEthWithdrawal( bytes32 signer, address to, uint256 amount, uint256 nonce, uint256 deadline, bytes calldata ownerSig ) external { LibReentrancyGuard.enforceNonReentrant(); if (!LibDirectMode.isDirectMode()) { revert LibDirectMode.DirectAccessNotAvailable(); } if (block.timestamp > deadline) revert WithdrawalExpired(); if (to == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); bytes32 structHash = _hashDirectWithdrawal( signer, address(0), to, amount, nonce, deadline ); bytes32 digest = LibEIP712.toTypedDataHash(structHash); bytes32 signerKey = LibAuth.verifyOwnerSignature(digest, ownerSig); if (signerKey != signer) revert LibAuth.SignerMismatch(signer, signerKey); LibAuth._useCheckedNonce(signerKey, nonce); LibAuth.enforceNotPendingRemoval(signerKey); (bool success,) = to.call{value: amount}(""); if (!success) revert EthTransferFailed(); }Recommendation
Bind deployment to the intended first signer set. The safest fix is to include a commitment to the initial signer data in the CREATE2 salt or require a user-signed deployment authorization that names the
walletUuid, factory, chain ID and initial signer set.If UUID-only addresses must remain unchanged, treat the factory owner and every
isDeployeraddress, including the initial executor, as custody-critical for undeployed funded wallets. Keep deployer authority away from hot executor keys and add operational checks that prevent a deployer from choosing arbitrary initial signers for a funded UUID. - A user or integrator computes the wallet address from
-
M-02 Medium Prefunded wallets inherit mutable defaults Unexpected Behavior Acknowledged
Description
Wallet addresses are predictable before deployment, but the daily allowances installed at deployment are not bound to that prediction.
initializeWithPasskey,initializeWithOwnerandinitializeWithBothall call_applyDefaultAllowances. That helper reads the factory's current default daily limits and writes them into the wallet during initialization.The factory owner can change default daily limits before a wallet is deployed. If a user or integrator computes a wallet address from
walletUuidand sends ETH or tokens to that address before deployment, the wallet can later be deployed with a different default allowance configuration than the one expected when the funds were sent. This does not require the attacker-controlled signer scenario from the UUID deployment issue. The wallet can be initialized with the correct user signer and still inherit mutable executor spending limits chosen after the address was prefunded.For example:
Day 1: The user predicts a wallet address from walletUuid. Factory defaults are harmless, such as USDe daily limit = 0. The user sends funds to the predicted address. Day 2: The factory owner changes default daily limits. Future wallets now receive USDe daily limit = 100,000. Day 3: The wallet is deployed at the predicted address with the correct user signer. It inherits the Day 2 limits, not the limits expected when funds were sent.Consequently, prefunded counterfactual wallets can start life with executor daily allowances the user did not approve for that wallet. The executor can then spend up to those inherited limits before the user notices and overrides them.
Recommendation
Do not apply mutable factory defaults to prefunded counterfactual wallets without a deployment-time commitment. The safest option is to initialize new wallets with zero daily limits and require the owner to set limits after deployment.
If defaults must remain, bind the expected default-limit hash into wallet deployment. For example, make deployment take an
expectedDefaultLimitsHashand revert if the factory defaults changed between address prediction and deployment. Documentation and integrations should also warn users not to prefund predicted wallet addresses unless the wallet is already deployed or the default allowance configuration is pinned. -
M-03 Medium Preauthorized hashes ignore pending removal Validation Acknowledged
Description
A preauthorized hash is a stored approval for a specific external order. When an external protocol later calls
isValidSignature(hash, sig), the wallet returns the ERC-1271 magic value ifLibPreAuthorizedHash.isAuthorized(hash)says that stored approval is still valid.LibPreAuthorizedHash.isAuthorizedchecks whether the entry expired, whether the authorizing signer is still registered and whether the executor epoch is still current. It does not check whether the authorizing signer is pending removal.This creates a gap in the pending-removal quarantine. Normal owner-authenticated functions call
LibAuth.enforceNotPendingRemoval, so a signer queued for removal cannot keep authorizing sensitive wallet actions. New preauthorizations are also blocked becauseexternalApproveAndPreAuthorizeHashchecksenforceNotPendingRemovalbefore storing a hash. However, hashes stored before the signer was queued for removal remain valid.For example, signer A can preauthorize an external order hash while it is healthy. If the user later suspects signer A is compromised and queues it for removal, signer A is still registered until the removal cooldown ends. During that cooldown, an external protocol can ask the wallet whether the old order hash is valid. The wallet still returns the ERC-1271 magic value because
isAuthorizedonly checks that signer A is registered, not that signer A is pending removal.Consequently, queuing a signer for removal does not suspend external orders that signer already authorized. This weakens pending removal as an emergency containment step for a suspected compromised signer.
Recommendation
Make
LibPreAuthorizedHash.isAuthorizedreturn false whenentry.signerKeyis pending removal:if (LibAuth.isPendingRemoval(entry.signerKey)) { return false; }The check should match the pending owner and passkey removal logic already used by owner-authenticated state changes. For stronger containment, revoke signer-scoped preauthorizations and clear their paired deadline-gated approval entries when a signer removal is initiated or executed.
-
M-04 Medium Approve and call can leave approvals Trust Assumptions Acknowledged
Description
executorExternalCallblocks several approval-mutating selectors before it calls an external target.executorApproveAndExternalCalldoes not apply the same guard to itsdata.That function grants a temporary allowance to the supplied
spender, calls arbitrarytarget.call(data), then clears only the allowance for the original(token, spender)pair. Ifdatacalls a token or operator contract and creates a different approval, that second approval is not cleared.For example, the signed payload can set
tokenandspenderto a legitimate router while settingtargetto an ERC-20 token anddatatoapprove(attacker, type(uint256).max). The function will clear the router allowance after the call, but the attacker allowance remains live. The same issue applies to approval-like functions that authorize a different spender or operator.This requires both owner and executor signatures. The impact is still meaningful because the function's cleanup gives users and integrators the impression that the approval granted for the external call cannot persist after execution.
Recommendation
Apply the same selector guard used by
executorExternalCallbeforeexecutorApproveAndExternalCallperformstarget.call(data).A safer fix is to replace the short blocklist with an allowlist of supported targets and selectors. If generic calls remain supported, block standard ERC-20 approvals, legacy ERC-20 approval variants, ERC-721 and ERC-1155 operator approvals and known protocol-specific approval functions.
-
L-01 Low Immediate RP ID hash update can brick passkeys Compatibility Acknowledged
Description
setExpectedRpIdHashlets the factory owner replace the global WebAuthn RP ID hash immediately. There is no schedule, delay, cancellation window or grace period that accepts both the old and new RP ID hashes.LibAuthreads this factory value during WebAuthn verification and rejects authenticator data whose first 32 bytes do not match the configured hash. Therefore, a typo or malicious owner action can instantly make existing passkey assertions invalid for every wallet using the previous RP ID. Passkey-only wallets would lose the ability to authorize direct mode, direct withdrawals, signer management and recovery cancellation until the value is corrected or another signer type is available.Existing tests confirm that a configured mismatched RP ID hash causes WebAuthn verification to revert with
InvalidRpIdHash.Recommendation
Put RP ID hash changes behind the same governance delay used for other global wallet-risk changes. Use a two-step process with
scheduleExpectedRpIdHash,executeExpectedRpIdHashandcancelExpectedRpIdHash. For domain migrations, support a grace period where both the old and new RP ID hashes are accepted. -
L-02 Low Old executor keeps deployer role after rotation Trust Assumptions Acknowledged
Description
The factory has two separate privileges.
executoris the key or contract used to co-sign executor-authorized wallet operations.isDeployer[address]is a separate allowlist for addresses that can deploy new wallets.During initialization, the factory automatically grants deployer status to the initial executor by setting
isDeployer[_executor] = true. Later,executeExecutorChangerotatesexecutorand incrementsexecutorEpoch, but it does not updateisDeployer.Consequently, an old executor loses the ability to co-sign new executor operations, but it can still deploy wallets if it was previously left in
isDeployer. The deployment functions checkonlyDeployer, not whethermsg.senderis the current executor. They also let the deployer choose the first owner or passkey for the new wallet.This matters because wallet addresses are deterministic from
walletUuid; the initial signer is not part of the address. If a user or integrator sends assets to a predicted wallet address before deployment, a stale old executor that knows thewalletUuidcan deploy that wallet with an attacker-controlled signer. The assets at the predicted address then become controlled by the attacker-selected signer.Recommendation
Do not couple deployment authority to the executor implicitly. Remove the automatic
isDeployer[_executor] = truegrant if executor keys are not meant to deploy wallets.If the executor must also be a deployer, update deployer roles atomically during executor rotation:
isDeployer[oldExecutor] = false; isDeployer[newExecutor] = true;If deployers are meant to be managed independently, document that separation. The executor rotation runbook should then explicitly revoke the old executor's deployer role in the same governance operation.
-
L-03 Low Emergency revokes require executor epoch Unexpected Behavior Acknowledged
Description
executorEpochis the factory's version number for the executor key. It is useful for executor-signed actions because old executor signatures should stop working after executor rotation.emergencyPermit2LockdownandemergencyRevokeApprovalare different. They are documented as owner-only emergency actions and they do not verify an executor signature. Even so, both functions require the owner to sign the currentexecutorEpoch, then both call_enforceExecutorEpoch.Consequently, an unrelated executor rotation can invalidate a still-fresh owner signature for emergency cleanup. For example, a user may sign an emergency Permit2 lockdown while
executorEpochis0. If governance rotates the executor before that transaction is submitted, the factory incrementsexecutorEpochto1. The lockdown then reverts withExecutorEpochMismatch(0, 1), even though the executor was never meant to authorize the lockdown.This is a bad failure mode for revocation functions. These functions exist so an owner can quickly cut off external approvals without executor cooperation during direct mode or another incident. Tying them to
executorEpochcan delay that protective cleanup and leave the approval live until the user signs and submits a new payload.Recommendation
Remove
executorEpochfrom owner-only emergency revocation payloads. These functions should not call_enforceExecutorEpochbecause the executor does not sign or approve them. Keep the owner signer, nonce and deadline checks.If the protocol wants emergency signatures to expire on a governance boundary, use a dedicated revocation epoch for owner-only emergency actions. Do not reuse the executor epoch unless the executor also signs the payload.
-
L-04 Low Batch executor can disable blocklist Unexpected Behavior Acknowledged
Description
Direct daily allowance spends rely on
factory.batchExecutor()for the recipient blocklist.AllowanceFacet.spendDailyAllowanceandspendDailyAllowanceEthdo not store blocklist state in the wallet. They read the currentbatchExecutoraddress from the factory, then ask that contract whether the recipient is blocked.If
batchExecutor()returnsaddress(0), the wallet skips the blocklist check completely. If it returns a contract, the wallet trusts that contract'sblocked(address)response.setBatchExecutorcan change this address immediately. It isonlyOwner, but it has no timelock. It also explicitly allowsaddress(0), which disables the wallet-level blocklist. For nonzero addresses, the only validation is that the target has code and exposes a callableblocked(address)function. The factory does not require the target to be the realBatchExecutoror a governed recipient-policy contract.For example:
Normal state: factory.batchExecutor = real BatchExecutor The wallet asks the real BatchExecutor whether the recipient is blocked. Blocked recipients are rejected. After setBatchExecutor(address(0)): factory.batchExecutor = address(0) The wallet sees no blocklist contract and skips the check. Blocked recipients can receive direct allowance spends.The same issue exists with a fake replacement contract. A compromised or mistaken factory owner can point
batchExecutorat a contract whoseblocked(address)function always returnsfalse. Existing wallets then keep calling a blocklist hook, but the hook no longer enforces the intended policy.This is not permissionless. It requires factory-owner authority or a compromised owner. The issue is that the change is instant, global and affects existing wallets' direct allowance-spend protection.
Recommendation
Put
setBatchExecutorbehind the same scheduled governance process used for executor and facet changes. After production setup, do not allowaddress(0)unless the change is timelocked and clearly monitored.Consider separating the recipient-policy registry from the batch relay contract. The wallet should read blocklist state from a dedicated, governed policy contract rather than from a mutable relay address.
-
L-05 Low Pending recovery initiator can become executor Validation Acknowledged
Description
RecoveryFacet.initiateRecoveryenforces role separation when recovery starts. The caller must be a recovery admin and the call reverts if that same address is the current factory executor. This prevents the executor from using recovery-admin authority to add a wallet signer.Recovery is delayed for 10 days. During that delay, the factory executor can be rotated.
executeRecoverychecks thatrs.pendingInitiatoris still a recovery admin and that the recovery admin config hash has not changed, but it does not check whether that same pending initiator has become the current executor.For example, admin
Acan initiate recovery while the executor isE. If the factory executor is later rotated toAbefore the timelock ends,executeRecoverycan still complete the pending recovery becauseAremains in the admin list and the admin list itself is unchanged. The result contradicts the rule enforced byinitiateRecovery: the current executor should not be able to act through recovery-admin authority.The existing
owner == currentExecutorinvalidation only covers an address signer that would be added by the recovery. It does not cover the recovery initiator and it does not cover passkey recoveries. Consequently, the executor and recovery-admin roles can overlap for an already pending recovery after a factory-level role rotation.Recommendation
Recheck the pending recovery initiator against the current executor inside
executeRecovery. Ifrs.pendingInitiator == IUserProxyFactory(factory).executor(), clear the pending recovery and emitRecoveryInvalidated.Apply the same role-separation check to any other recovery-admin role that should not remain valid after factory-level role rotation.
-
L-06 Low Default recovery admins can become unusable Validation Acknowledged
Description
scheduleDefaultRecoveryAdminsvalidates the pending default recovery admins against the executor that is current at scheduling time.executeDefaultRecoveryAdminslater copies the pending admins into_defaultRecoveryAdminswithout re-running that validation.Executor changes are scheduled and executed separately. Therefore, the factory can schedule a recovery-admin set that is valid against the old executor, rotate the executor before the recovery-admin update is executed and then install a default admin that equals the current executor. The same stale configuration can also arise when the executor is rotated after defaults are already active.
The wallet recovery code now rejects the current executor as a recovery admin when
initiateRecoveryis called. That prevents role collapse, but it also means the configured default admin may be unusable. Wallets that rely only on factory default recovery admins can temporarily lose effective recovery until governance installs a new default admin set.Recommendation
Re-run
LibRecovery.validateAdminsagainst the current executor insideexecuteDefaultRecoveryAdminsbefore writing_defaultRecoveryAdmins.Also guard executor rotation against known default recovery admins. If the pending executor equals an active or pending default recovery admin, require governance to update or cancel the recovery-admin configuration first. At minimum, emit a clear warning event or expose a view that reports default admins made ineffective by the current executor.
-
L-07 Low Allowance spends check relayer for removal Unexpected Behavior Acknowledged
Description
spendDailyAllowanceandspendDailyAllowanceEthtry to enforce the pending-removal quarantine on the wrong address. After verifying the executor signature, both functions derive asignerKeyfrommsg.senderand pass that key toLibAuth.enforceNotPendingRemoval.That check does not identify a wallet signer. These spend functions are intentionally callable by anyone with a valid executor signature, so
msg.senderis only the account submitting the transaction. The executor-signed payload binds the recipient, amount, nonce, deadline and executor epoch. It does not bind a wallet owner or passkey signer and these functions do not verify an owner signature.For example, if owner
Ais pending removal and submits an executor-signed allowance spend directly, the check may reject becausemsg.sendermaps toA. The same signed spend can be submitted by relayerR. In that case the contract checksRinstead ofAand the pending-removal quarantine does not apply.Consequently, this check is only a misleading security control. The code appears to block pending-removal signers from triggering daily allowance spends, but no signer is actually tied to the spend authorization. The only meaningful authorization for these functions is the executor signature.
Recommendation
Remove the pending-removal check from allowance spend functions if daily allowance spending is meant to be executor-only.
If pending-removal signers must be unable to trigger these spends, bind a signer key into the spend payload and verify an owner signature for that signer before the transfer. Then run
LibAuth.enforceNotPendingRemovalon the verified signer key, not onmsg.sender. -
L-08 Low Recipient blocklist is not wallet-wide Access Control Acknowledged
Description
The wallet screens fund destinations against the BatchExecutor blocklist with
_enforceRecipientNotBlocked(AllowanceFacet), but only calls it on the two allowance-spend paths (spendDailyAllowance,spendDailyAllowanceEth). Every other value or approval exit moves funds without it, so the blocklist is per-facet, not wallet-wide. A destination rejected on an allowance spend is still reachable through:- Executor withdrawals.
WithdrawalFacet.executorTokenWithdrawal/executorEthWithdrawalsend to an arbitrarytowith no screen:
IERC20(token).safeTransfer(to, amount); (bool success,) = to.call{value: amount}("");- Generic external calls.
ExecutorExternalCallFacet.executorExternalCall/executorApproveAndExternalCallcall anytargetwith anydataand nativevalue, unscreened. Their only filter is a selector denylist that omitssetApprovalForAll(0xa22cb465),transfer,transferFrom, and nativevalue:
if (selector == IERC20.approve.selector || selector == bytes4(keccak256("increaseAllowance(address,uint256)")) || selector == bytes4(keccak256("approve(address,address,uint160,uint48)"))) revert ApprovalSelectorBlocked();So an executor-signed
transfer(blocked, amount), a native send to a blocked target, orsetApprovalForAll(operator, true)all pass.- Approval grants.
ExternalApproveFacet.externalApproveandExternalApproveAndPreAuthorizeHashFacet.externalApproveAndPreAuthorizeHashapprove an arbitraryspenderwith no screen:
IERC20(token).forceApprove(spender, amount);A blocked spender then pulls the approved amount, the same outflow the blocklist is meant to stop. All paths need a valid owner plus executor co-signature, so this is a policy completeness gap, not an unprivileged theft path.
Recommendation
Route every value and approval exit through one shared recipient-screen helper, and invoke it for the withdrawal
to, the external-calltargetand token recipient, and the approvalspender/operator. - Executor withdrawals.
-
L-09 Low ProxyAdmin delay check can brick the kill switch DoS Acknowledged
Description
The remediation added
_validateProxyAdminDelayand wired it into four factory governance functions:execzuteUpgrade,executeExecutorChange,executeFacetUpdate, andexecuteDefaultRecoveryAdmins. The most sensitive isexecuteExecutorChange, the executor kill switch: it is the only function that incrementsexecutorEpoch, which is what invalidates a compromised executor's signed operations and standing pre-authorized orders. Operators reach for it precisely when the executor key is suspected compromised._validateProxyAdminDelayreads the ProxyAdmin from the ERC-1967 admin slot. If the ProxyAdmin is a contract it staticcallsowner(); if that owner is also a contract it staticcallsgetMinDelay()and requires at leastUPGRADE_DELAY(48h). When the owner has nogetMinDelay()it revertsProxyAdminOwnerNotTimelock; when the delay is too short it revertsProxyAdminDelayTooShort. The check runs at the top of all four functions, so each reverts whenever the ProxyAdmin owner is a contract that is not a TimelockController.This trigger is common and benign: the standard pattern owns a ProxyAdmin with a plain multisig such as a Gnosis Safe, which has no
getMinDelay(), so the check reverts. In that state an operator cannot rotate the executor or bumpexecutorEpoch, so during a compromise the executor's signed operations and standing orders cannot be invalidated on-chain. Upgrades, facet updates, and recovery-admin refreshes are blocked too.This stays Low: there is no unprivileged attacker (the trigger is the operator's own privileged choice of ProxyAdmin owner), and it is recoverable by moving ProxyAdmin ownership to a TimelockController with a delay of 48h or more, though that recovery is itself a governance action and awkward mid-incident.
Note the escape hatch is inverted: an EOA owner returns early and passes, while a multisig owner reverts. So the least-safe shape (a single EOA) sails through, while hardening ProxyAdmin ownership to a Safe silently disables the kill switch. The check also does not close the original issue: a direct
ProxyAdmin.upgradeAndCallon the factory proxy never routes through these four functions, so the delay assertion does not constrain it.Recommendation
Document that a multisig or Safe-owned ProxyAdmin (anything without a TimelockController-style
getMinDelay()) makesexecuteExecutorChange,executeUpgrade,executeFacetUpdate, andexecuteDefaultRecoveryAdminsrevert and disables the kill switch until ProxyAdmin ownership is moved to a 48h timelock, or treat a missinggetMinDelay()as a skip rather than a hard revert. -
L-10 Low Batch subcall gas can starve later items Unexpected Behavior Acknowledged
Description
batchExecuteonly checks whethergasleft()is belowGAS_RESERVEbefore starting each item. It then forwardsgasleft() - GAS_RESERVEto the wallet call. The fixed reserve protects some caller-side cleanup, but it still lets one successful item consume nearly all gas available to the shared batch.This matters because wallet calls can make recipient-controlled calls. For example,
spendDailyAllowanceEthsends ETH withto.call{value: amount}(""). An approved recipient can accept the payment and burn gas inreceive()before returning successfully. Consequently, that item can settle while later valid items in the same batch fail withfalseor remain unattempted because the loop no longer has enough gas to continue.Recommendation
Forward a bounded per-item gas amount instead of almost all remaining gas. The batch executor should also reserve enough gas for the remaining loop work, result writes, event emission and return data.
If some approved recipients are expected to be gas-heavy, isolate them from unrelated settlements or meter them through a separate batch policy so one successful payment cannot consume the gas budget for later items.
-
L-11 Low Stale exits end later direct-mode sessions Unexpected Behavior Acknowledged
Description
InitiateExitDirectModesignatures bind only the signer, nonce and deadline. They do not bind the activedirectModeAttimestamp. As a result, a valid but unused exit signature can be submitted during a later direct-mode session if the signer nonce has not changed and the deadline has not expired.DirectModeFacet.initiateExitDirectModeverifies that stale signature, consumes the signer nonce and callsLibDirectMode.initiateExitDirectMode. That function only checks that the wallet is currently in direct mode and that no exit is already pending. It does not know which direct-mode session the signer meant to exit.Consequently, any holder of the old signature can schedule an exit for a later direct-mode session. After the normal one-hour exit cooldown,
isDirectMode()returns false for that later session. During a global pause, this can temporarily remove the user's direct-withdrawal escape option until the user cancels the pending exit or starts direct mode again after another cooldown.Recommendation
Include the active
directModeAttimestamp in theInitiateExitDirectModeEIP-712 payload and verify that it still matches wallet state before scheduling an exit.Also consider storing enough session context for exits so
isDirectMode()ignores a matured exit that was not created for the current direct-mode activation. -
L-12 Low No-op recovery config cancels pending recovery Unexpected Behavior Acknowledged
Description
setRecoveryConfigclears any active pending recovery before it rewrites the recovery admin list. It does this for every non-emptyadminsarray and does not compare the submitted roster to the wallet's current effective recovery admins.Consequently, an idempotent config update can act as a recovery cancellation. If an unused owner-signed
SetRecoveryConfigpayload still matches the current admin roster, submitting it during an active recovery consumes the signer nonce and clearsrs.pendingActive, even though the recovery configuration did not actually change.This bypasses the intent-specific binding used by
cancelRecovery.CancelRecoverysigns the current pending recovery target throughpendingNewSignerHash, whileSetRecoveryConfigonly signssigner,configHash,nonceanddeadline. A holder of one valid no-op config-update signature can therefore delay recovery once without aCancelRecoverysignature. The impact is bounded to liveness griefing because the nonce is consumed and recovery admins can initiate recovery again.Recommendation
Only clear pending recovery when the effective recovery admin roster actually changes. Compare the submitted admins against
LibRecovery.getEffectiveAdminsbefore callingLibRecovery.clearPendingRecovery.If config updates are intended to cancel recovery even when the roster is unchanged, bind that intent explicitly into the signed data. For example, include the active recovery identifier or
pendingNewSignerHashin the signed config-update payload when cancellation is allowed. -
L-13 Low Beacon wallets reject 2300-gas native transfers Unexpected Behavior Acknowledged
Description
User wallets are deployed as OpenZeppelin
BeaconProxycontracts. The proxy has no cheapreceive()function of its own. Therefore, an empty-calldata native AVAX transfer to a wallet proxy enters the OpenZeppelin proxy fallback, calls the beacon to read the current implementation and then delegatecalls intoWalletDiamond.receive().That work costs more than the 2300 gas stipend forwarded by Solidity
transfer()andsend(). Third-party contracts that hardcode those send methods for AVAX refunds or payouts will fail when the recipient is an EthenaPay wallet. This does not block native deposits made withcall{value:}and sufficient gas and the core wallet withdrawal code already uses low-level calls for native transfers.A focused Foundry test confirmed the behavior on a real deployed wallet proxy from the project fixture:
transfer()reverted,send()returned false andcall{value:, gas: 100_000}funded the wallet.Recommendation
Document that native AVAX senders must use
call{value:}with enough gas when sending to EthenaPay wallets.If compatibility with
transfer()andsend()is required, deploy wallets through a proxy shell that implements a cheap proxy-levelreceive()function and stores the funds without performing a beacon lookup or delegatecall. -
L-14 Low ECDSA bypasses code-owner ERC-1271 policy Validation Acknowledged
Description
LibAuth.verifyOwnerSignaturelets the caller choose the signature type with the first byte ofownerSig. When that byte is0x00,_verifyECDSArecovers an address and accepts the signature if the recovered address is a registered owner. It does not reject owners that currently have code.The ERC-1271 verifier handles the same address class differently. For
0x01signatures,_verifyEIP1271requires the owner address to have code and then callsSignatureChecker.isValidSignatureNow. Consequently, a code-bearing owner can reject an operation through its ERC-1271 policy but still authorize the same wallet operation with a raw secp256k1 signature.This matters for EIP-7702-style delegated EOAs if their contract code is meant to define the active signing policy. In that model, compromise or unintended use of the raw EOA key bypasses the delegated account's
isValidSignaturepolicy for EthenaPay operations.Recommendation
Persist the owner kind at registration and enforce the matching signature type during verification. Code-bearing owners that are registered as smart-contract owners should authenticate through ERC-1271.
As a smaller hardening change, reject ECDSA verification when the recovered registered owner currently has code. If raw EOA authority is intentionally trusted for delegated EOAs, document that behavior explicitly so integrators do not assume
isValidSignatureis authoritative for those owners. -
L-15 Low Pause probe blocks direct-mode signer changes Validation Acknowledged
Description
enforceUserNotBlocked()callsisGlobalPaused()before it checks whether the wallet is already in direct mode.isGlobalPaused()intentionally fails closed and reverts withGlobalPauseActivewhen the factoryglobalPaused()probe reverts or returns malformed data. Therefore, a broken pause probe prevents the direct-mode exemption from being evaluated.This contradicts the documented direct-mode guarantee for signer management. With a working
globalPaused() == trueprobe, an already-direct-mode wallet can still approve signer removal. With onlyglobalPaused()mocked to revert, the same direct-mode signer-removal operation reverts withGlobalPauseActive.The impact is limited to the modeled factory-probe outage. Direct withdrawals still work in direct mode and
revokePreAuthorizedHash()is not affected in the current code because it no longer callsenforceUserNotBlocked(). The remaining issue is that users can lose active signer-management controls, such as finalizing removal of a compromised signer, until the factory pause probe is repaired.Recommendation
Check local direct-mode state before probing the factory for user-gated operations. For example:
function enforceUserNotBlocked() internal view { if (isDirectMode()) return; if (isGlobalPaused()) revert GlobalPauseActive(); }Alternatively, use a non-reverting pause-probe helper for this guard and keep the fail-closed behavior only where direct mode is not supposed to override global pause.
-
L-16 Low Stale pending owner additions outlive removal Unexpected Behavior Acknowledged
Description
executeRemoveOwnerremoves an address owner but does not clear pending owner additions for the same address. This matters becauseaddOwnerDirectcan add an address through recovery while an existing pending owner addition for that address is still live.For example, a recovery admin can initiate recovery, then a current signer can initiate a normal owner addition for the same address late enough in the recovery delay that the pending addition remains within its 7-day TTL. When recovery later executes,
addOwnerDirectadds the address but leaves the pending addition in storage.approveAddOwneris blocked only while the address is still an owner, so the pending addition remains retryable. If that owner is removed before the pending addition expires, a current signer can approve the old action immediately and re-add the removed owner without a fresh 24-hour initiation period.Recommendation
Invalidate pending owner additions that target an address whenever that address is removed. Also clear any matching pending owner addition inside
addOwnerDirectbefore or after adding the recovered owner.Alternatively, track a per-owner generation counter and bind pending owner additions to that generation. Increment the generation on removal so older pending additions cannot be approved after the address is removed.
-
L-17 Low Same-block direct-mode cancels collide Unexpected Behavior Acknowledged
Description
CancelDirectModeandCancelExitDirectModesignatures are meant to cancel one specific pending direct-mode action. The signed payload includes the signer nonce, deadline and the pending activation timestamp currently stored in the wallet. It does not include a unique action id or the digest of the action that the signer intended to cancel.That timestamp is not unique.
initiateDirectModesetsdirectModeAttoblock.timestamp + 1 hoursandinitiateExitDirectModesetsexitDirectModeAtthe same way. If a pending action is cancelled and then recreated in the same block, the new action receives the same pending timestamp as the old one.As a result, an unused cancel signature for the old pending action can also cancel the freshly recreated action. Anyone holding that signature can submit it, consume the signer's nonce and clear the new direct-mode enter or exit request.
For example:
- At timestamp
T, signer A schedules direct mode, sodirectModeAt = T + 1 hours. - Signer B signs a cancellation for that pending direct-mode request, but the signature is not submitted immediately.
- In the same block, the first request is cancelled and a new direct-mode request is created.
- Because the block timestamp is still
T, the new request also storesdirectModeAt = T + 1 hours. - The old cancellation signature now matches the new request and can cancel it even though the signer approved cancelling the previous request.
The impact is temporary liveness griefing, not fund loss. The stale cancel is one-shot because submitting it consumes the signer's nonce. The collision also disappears once a later block gives the recreated action a different timestamp.
Recommendation
Bind cancel signatures to a value that uniquely identifies the pending action. A monotonic direct-mode action nonce is the simplest fix. Increment it whenever
initiateDirectModeorinitiateExitDirectModecreates a new pending action, include that action nonce in the corresponding cancel EIP-712 payload and verify that it still matches storage before clearing the pending state.As a weaker operational mitigation, avoid reinitiating direct-mode enter or exit in the same block after cancellation. This avoids the timestamp collision, but it relies on caller behavior and should not replace protocol-level action binding.
- At timestamp
-
L-18 Low Zero-coordinate P-256 passkeys are rejected Validation Acknowledged
Description
Passkey registration rejects any P-256 public key whose
qxorqycoordinate is zero before it asksP256.isValidPublicKeywhether the point is on the curve. This excludes mathematically valid secp256r1 public keys. For example, OpenZeppelin'sP256.isValidPublicKeyaccepts a real point withqx == 0, but the wallet rejects it first withInvalidPublicKey.The same zero-coordinate precheck is repeated in
LibAuth.initiateAddPasskey,LibAuth.addPasskeyDirectandRecoveryFacet.initiateRecovery. The factory deployment functions also reject zero passkey coordinates before the wallet initializer runs.Consequently, a user whose authenticator produces a valid WebAuthn key with a zero coordinate cannot use that passkey for wallet initialization, signer addition or recovery. This is a low-likelihood compatibility issue rather than a theft vector, because random authenticator-generated keys should almost never hit this edge case.
Recommendation
Remove the blanket zero-coordinate rejection for P-256 passkeys and rely on
P256.isValidPublicKeyto decide whether the submitted coordinates are valid.Keep the existing duplicate checks, signer-count checks and format checks. Apply the same validation rule consistently in wallet initialization, passkey addition, direct recovery add and
RecoveryFacet.initiateRecovery. -
L-19 Low Zero externalApprove records ghost owner Unexpected Behavior Acknowledged
Description
externalApproverecords a deadline-gated approval entry even whenamountis zero. The function first clears the ERC-20 allowance withforceApprove(spender, 0), but then it still creates a newapproveHash, stores an approval entry and marks that hash as the current owner for the(token, spender)slot.Consequently,
currentApprovalOwnerandapprovalEntrycan report a live approval owner even though the token allowance is already zero. Permissionless cleanup throughclearStaleApprovalis also unavailable untilapprovalDeadline, so the stale metadata remains visible until the deadline passes.This does not let the spender pull funds. A later tracked approval for the same token and spender can also replace the ghost owner. The impact is misleading wallet accounting and delayed metadata cleanup, not direct fund loss.
Recommendation
Special-case zero-amount approvals in
externalApprove.After
forceApprove(spender, 0), clear the current owner for the(token, spender)slot and skipsetEntryfor the zero approval. Only create a new deadline-gated approval entry whenamountis nonzero. -
I-01 Informational Emergency revocations emit no events Events Acknowledged
Description
emergencyPermit2LockdownandemergencyRevokeApprovalperform emergency cleanup but emit no dedicated event.emergencyPermit2Lockdowncalls Permit2lockdownandemergencyRevokeApprovalcan revoke a preauthorized hash, clear deadline-gated approval metadata and set an ERC-20 allowance to zero. None of those owner-only emergency actions produces a wallet-level event that clearly identifies the cleanup.This makes incident response and monitoring weaker than the normal approval flows. Indexers can observe token
Approvalevents or Permit2-specific events if those integrations emit them, but the wallet does not emit a canonical record that an emergency owner-authorized cleanup was executed. Off-chain systems therefore need protocol-specific decoding and can miss the user-protective action at the wallet layer.Recommendation
Emit dedicated wallet events for both emergency functions. The events should include the verified signer key, the affected token and spender or Permit2 address and enough metadata to distinguish the cleanup from normal executor-approved external calls.
For Permit2 lockdowns, emit the Permit2 address and a hash of the approval list. For ERC-20 revocations, emit the token, spender and any preauthorized hash that was revoked.
-
I-02 Informational Direct NFT transfers lack target checks Warning Acknowledged
Description
directNFTTransferanddirectERC1155Transferreject zero token-contract addresses, but they do not check that the supplied address contains code or supports the expected token interface before consuming the signer nonce.For a normal ERC-721 or ERC-1155 token, the token contract enforces ownership and transfer rules. The problem is malformed input. If a signer supplies an EOA or an unrelated contract with a permissive fallback as the token address, the wallet can consume the direct-mode nonce and emit a direct transfer event even though no NFT or ERC-1155 balance moved.
Recommendation
Before consuming the nonce, require the token address to contain code. Consider also checking ERC-165 support for the expected interface, using
type(IERC721).interfaceIdfordirectNFTTransferandtype(IERC1155).interfaceIdfordirectERC1155Transfer.If compatibility with non-ERC165 tokens is required, at least reject
tokenContract.code.length == 0and document that interface validation is intentionally delegated to the token call. -
I-03 Informational Base64URL challenge encoding overflows output Unexpected Behavior Acknowledged
Description
Base64._encodecomputes a shorter output length when URL-safe Base64 is requested without padding. For a 32-byte input, the declared result length is 43 bytes. The encoder loop still writes every input chunk as a full four-character quartet, so the final partial chunk writes a 44th byte at the current free-memory pointer.Ethena Pay reaches this code during WebAuthn verification.
LibAuth._verifyWebAuthnconverts the signed digest into a 32-byte challenge, then OpenZeppelin'sWebAuthn._validateChallengecallsBase64.encodeURL(challenge)while checking the client data JSON. Consequently, each passkey verification executes the out-of-bounds memory write before continuing with normal challenge comparison and P-256 signature checks.The observed write is currently overwritten by the next allocation in the surrounding WebAuthn challenge comparison. A local test confirmed the stray byte and also confirmed that the next allocation clears it. No authentication bypass, fund loss or passkey lockout was demonstrated. The issue is still worth fixing because the encoder violates Solidity memory-safety assumptions and leaves the result dependent on nearby allocation behavior.
Recommendation
Patch or upgrade the vendored Base64 encoder so unpadded URL-safe encoding only writes the declared number of output bytes. The final partial input block should write two or three output bytes as appropriate, not a full four-byte quartet. Alternatively, keep the padded quartet in allocated memory and return a correctly trimmed object without leaving bytes outside the returned string.
-
I-04 Informational Factory init optimizes out EIP-1153 probe Unexpected Behavior Acknowledged
Description
initialize()is documented as probing EIP-1153 support by executingtload(0). The result is assigned to a local assembly variable that is never consumed. With the configured optimized via-IR Cancun build, the compiler removes the unused probe from the factory runtime bytecode.This breaks the documented CC-EIP1153/T2-G deployment check. Operators and auditors can believe the factory independently verifies transient-storage support during initialization, but the optimized factory runtime contains no executable
TLOADorTSTORE. This matters because wallet facets still depend onLibReentrancyGuard, which uses transient storage for guarded user operations.The practical impact is limited. Current production artifacts still contain other Cancun-only opcodes such as
MCOPY, so a standard pre-Cancun deployment is expected to fail before the factory is usable. Therefore this does not demonstrate a trapped-funds scenario. The issue is that the stated fail-fast compatibility gate is not actually present as a separate runtime check.Recommendation
Make the probe observable so the optimizer cannot remove it. For example, write and read a transient-storage sentinel, then consume the loaded value in a condition that cannot be proven dead by the optimizer.
-
I-05 Informational Batch partial failures lack item log attribution Events Acknowledged
Description
batchExecutereturns abool[]result array that identifies which batch items succeeded and failed. The same function only emitsBatchExecuteCompletedwith aggregaterequested,attemptedandsucceededcounts.Transaction receipts include logs, but they do not expose Solidity return data. Consequently, a monitor that relies only on
BatchExecutorlogs can detect that a batch partially failed, but it cannot identify which item indices failed from the event alone.Recommendation
Emit per-item result data from
batchExecuteor include a compact bitmap/hash of theresultsarray inBatchExecuteCompleted. The emitted data should let logs-only monitors map each attempted item index to its success or failure status without relying on return-data capture or trace tooling.
No findings match.
More from Ethena
All 7 reportsPut your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.