Spending Limits
Session-key-driven spending policies for native ETH and ERC20 tokens with automatic renewal periods
ERC7579NativeSpender and ERC7579ERC20Spender are concrete session-key policy modules that grant per-key spending allowances on a smart account. They are useful for subscriptions, recurring payments, agent budgets, gaming sessions, or any case where the account owner wants to delegate a bounded amount of value to a session key without exposing the primary key.
Each session key carries its own (limit, renewPeriod) policy. The window starts at the session key's from timepoint (managed by ERC7579SessionKeys) and renews automatically every renewPeriod time units, so the same allowance is available again in the next period without owner intervention.
Architecture
Both modules are simultaneously validators and executors:
- As a validator, the module authorizes a UserOp signed by a registered session key, provided
userOp.callDatais a single call into the module's owntransferFromorbatchTransferFrom. The session key's validity range is packed intovalidationDataso the EntryPoint enforces it natively — the validator does not need to re-check the range itself. - As an executor, the module spends from the account through
executeFromExecutor, decrementing the per-key, per-period limit before the transfer.
This means the module MUST be installed under both module types. The install is a two-step process:
First install (MODULE_TYPE_VALIDATOR): register session keys
Second install (MODULE_TYPE_EXECUTOR): write the spending policiesThe two installs commit one half of the state each. Once any policy is written, the module is "fully installed" and further onInstall calls revert with AlreadyInstalled. To change a policy, the account must uninstall and reinstall (which bumps the installation nonce and clears all state).
Session keys are immutable once configured: you cannot change a key's (limit, renewPeriod) or add new tokens to an
already-installed key. Plan all policies up-front in the second install, or use addKeysWithConfig to register fresh
session keys with their own policies.
ERC7579NativeSpender (ETH)
One session key has one (limit, renewPeriod) policy. The session key authorizes ETH transfers from the account up to its per-period limit.
Installing
Install the module under both module types in the same atomic flow. The two initData payloads have different shapes:
import {MODULE_TYPE_VALIDATOR, MODULE_TYPE_EXECUTOR} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol";
import {ERC7579NativeSpender} from "@openzeppelin-accounts/contracts/modules/executors/ERC7579NativeSpender.sol";
ERC7579NativeSpender nativeSpender = new ERC7579NativeSpender(); // singleton
bytes[] memory keys = new bytes[](2);
keys[0] = abi.encodePacked(eoaSessionKey); // 20-byte EOA key
keys[1] = abi.encodePacked(p256Verifier, qx, qy); // ERC-7913 P256 key
uint48[] memory starts = new uint48[](2);
uint48[] memory ends = new uint48[](2);
starts[0] = 0; // 0 → resolved to clock()
ends[0] = uint48(block.timestamp + 7 days); // expires in 7 days
starts[1] = 0;
ends[1] = 0; // 0 → type(uint48).max (no expiry)
uint160[] memory limits = new uint160[](2);
limits[0] = 1 ether;
limits[1] = 0.5 ether;
uint48[] memory renewPeriods = new uint48[](2);
renewPeriods[0] = 1 days;
renewPeriods[1] = 30 days;
// First install: validator role - registers the session keys
account.installModule(
MODULE_TYPE_VALIDATOR,
address(nativeSpender),
abi.encode(keys, starts, ends)
);
// Second install: executor role - writes the spending policies
account.installModule(
MODULE_TYPE_EXECUTOR,
address(nativeSpender),
abi.encode(keys, limits, renewPeriods)
);Both installs can be bundled into a single transaction via the account's batch execute, or executed atomically
alongside the first session-key UserOp via ERC-8043 init mode.
Spending via UserOp (recommended)
A session key signs a UserOperation whose callData calls the module's own transferFrom (or batchTransferFrom):
// Inner call: the module's transferFrom
bytes memory innerCall = abi.encodeCall(
ERC7579NativeSpender.transferFrom,
(address(account), key, recipient, 0.3 ether)
);
// Outer call: account.execute(SINGLE, encodeSingle(module, 0, innerCall))
bytes memory execCalldata = abi.encodePacked(address(nativeSpender), uint256(0), innerCall);
bytes memory outerCallData = abi.encodeCall(
IERC7579Execution.execute,
(encodeMode(CALLTYPE_SINGLE, EXECTYPE_DEFAULT, 0, 0), execCalldata)
);
// Route the nonce through the spender as validator
uint256 nonce = uint256(uint160(address(nativeSpender))) << 96;
// Session key signs the UserOpHash
PackedUserOperation memory userOp = /* ... fill, sign with key ... */;
userOp.signature = abi.encode(key, sessionKeySignatureOverUserOpHash);The validator gate accepts only this exact shape:
- The outer call must be
IERC7579Execution.execute(...)withCALLTYPE_SINGLEmode. - The single execution's
targetmust be the module itself andvaluemust be0. - The inner call's selector must be
transferFromorbatchTransferFrom. - The
keyparameter inside the inner call must match the key inuserOp.signature(prevents one key's signature from charging another key's allowance).
Any deviation returns SIG_VALIDATION_FAILED without reverting.
EOA self-call (when the key is a plain EOA)
If the session key is a 20-byte EOA, its holder can call transferFrom directly from that EOA — skipping the EntryPoint entirely. The module verifies msg.sender == address(bytes20(key)) and the key's validity range manually:
// The EOA whose address bytes match `key` calls the module directly.
nativeSpender.transferFrom(address(account), key, recipient, 0.3 ether);This path still requires the module to be installed as MODULE_TYPE_EXECUTOR, since it eventually calls executeFromExecutor on the account. For non-EOA keys (P256, WebAuthn, ZKEmail, …) the UserOp flow is the only option.
Removing keys
removeKeys clears both the validity range and the per-key spending config. Spending history at the historical from timepoint is preserved in storage and can still be read via spent(account, key, period, historicalStart):
bytes[] memory oldKeys = new bytes[](1);
oldKeys[0] = abi.encodePacked(eoaSessionKey);
nativeSpender.removeKeys(oldKeys); // called by the accountERC7579ERC20Spender
Same dual-role design, with an explicit token axis. A single session key can hold spending policies for multiple tokens by listing it more than once in the executor install bundle.
Installing
import {ERC7579ERC20Spender} from "@openzeppelin-accounts/contracts/modules/executors/ERC7579ERC20Spender.sol";
ERC7579ERC20Spender erc20Spender = new ERC7579ERC20Spender(); // singleton
// Register one key
bytes[] memory keys = new bytes[](1);
keys[0] = abi.encodePacked(sessionKey);
uint48[] memory starts = new uint48[](1);
uint48[] memory ends = new uint48[](1);
starts[0] = 0;
ends[0] = uint48(block.timestamp + 30 days);
account.installModule(
MODULE_TYPE_VALIDATOR,
address(erc20Spender),
abi.encode(keys, starts, ends)
);
// Configure the same key for TWO tokens by repeating it across rows
bytes[] memory cfgKeys = new bytes[](2);
address[] memory cfgTokens = new address[](2);
uint160[] memory cfgLimits = new uint160[](2);
uint48[] memory cfgRenews = new uint48[](2);
cfgKeys[0] = keys[0]; cfgTokens[0] = USDC; cfgLimits[0] = 100e6; cfgRenews[0] = 30 days;
cfgKeys[1] = keys[0]; cfgTokens[1] = DAI; cfgLimits[1] = 1000e18; cfgRenews[1] = 30 days;
account.installModule(
MODULE_TYPE_EXECUTOR,
address(erc20Spender),
abi.encode(cfgKeys, cfgTokens, cfgLimits, cfgRenews)
);The tokens parameter MUST be a contract address. The module verifies token.code.length > 0 to catch typos at
install time. ERC20 transfers that return false (or that revert) trigger ERC7579ERC20SpenderFailedTransfer. Tokens
with no return value (older USDT-style) are supported.
Spending
The public surface mirrors the native module, with an extra token parameter:
erc20Spender.transferFrom(address(account), key, USDC, recipient, 50e6);
erc20Spender.batchTransferFrom(address(account), key, DAI, [r1, r2], [100e18, 200e18]);The validator gate is identical: outer execute(SINGLE, ...) into the module, inner selector must be one of transferFrom/batchTransferFrom, key consistency between signature and inner call.
Removing keys (different from native)
removeKeys on the ERC20 module clears the validity range but does not clear the (key, token) spending configs. If the same key bytes are re-registered later via plain addKeys, the previously-stored configs become active again. To re-register with a fresh policy, use addKeysWithConfig:
// Inherits the old configs:
erc20Spender.addKeys([key], [0], [0]);
// Overrides the old config for (key, USDC):
erc20Spender.addKeysWithConfig([key], [0], [0], [USDC], [200e6], [30 days]);The rationale is that multi-token configs would otherwise require unbounded gas to clear on removeKeys (one config per (key, token) pair). Document this for your operational runbook if you uninstall keys at scale.
Common security properties
- ERC-1271 disabled. Both modules deliberately make
isValidSignatureWithSenderreturn0xffffffff. A spending session key cannot be repurposed to sign EIP-2612 permits or other arbitrary off-chain digests on the account's behalf. - Past starts banned. Both modules override
_addKeysto reject anystarttimepoint older thanclock(). This prevents a removed-then-re-registered key from inheriting stale_spentForstorage slots written under a previous validity range. Passstarts[i] == 0to resolve to the current timepoint. - Reconfiguration banned. Once a policy is written, neither it nor any sibling policy can be reconfigured under the current installation. Change requires uninstall + reinstall, which bumps the installation nonce and resets state.
- No external
valuechannel. The validator gate rejects user operations where the outer execution carries non-zerovalueto the module, so the only ETH ever leaving the account is the one the module itself sends via_execute.
Atomic install + first use with ERC-8043
Because both module-type installs are routine installModule calls on the account, they compose with ERC-8043 init mode. A dapp can:
- Have the account owner sign an EIP-712
InitModulesauthorization for[VALIDATOR, EXECUTOR]with[validatorInitData, executorInitData]. - Have the session key sign the first
transferFrom/batchTransferFromuser operation. - Bundle both signatures into a single UserOp — the account installs both module roles inline, then validates the session key's signature through the freshly-installed validator.
This is the recommended onboarding path for end-user dapps: the owner authorizes once (off-chain), the session key handles everything else (on-chain) for the duration of its validity window.