A Mandate in LoopPay is a user-controlled permission that authorizes a specific app or protocol (the "spender") to pull funds from the user's wallet (the "owner") under predefined limits and constraints. Mandates enable secure, transparent, and revocable recurring payments on Base L2.
LoopPay Mandates provide the following guarantees to users:
- Spending Limits: No single transaction can exceed
perChargeLimit, and total cumulative spending cannot exceedtotalLimit - Time Bounds: Mandates are only active between
startTimeandendTime - Cooldown Protection: Minimum time of
cooldownSecondsmust elapse between consecutive debits - Revocability: Users can revoke mandates at any time, immediately stopping all future debits
- Pausability: Users can temporarily pause mandates without revoking them
- Limit Adjustments: Users can reduce or increase limits mid-life (but not below already-spent amounts)
- Non-Reentrancy: All state-changing operations are protected against reentrancy attacks
Mandates provide the following guarantees to apps and protocols:
- Permission Verification: On-chain verification that a user has granted specific spending permissions
- Predictable Limits: Clear upper bounds on what can be charged per transaction and in total
- Status Visibility: Transparent mandate status (Active, Paused, Revoked, Expired)
- Lifecycle Tracking: Access to created/updated timestamps and usage history
- Token Specification: Explicit token address for multi-token support
┌─────────┐
│ Created │ (status: Active)
└────┬────┘
│
├──→ Pause ──→ Resume ──→ (back to Active)
│
├──→ Revoke ──→ (status: Revoked, permanent)
│
└──→ Time Expires ──→ (status: Expired, computed dynamically)
| Current State | Action | New State | Reversible? |
|---|---|---|---|
| Active | Pause | Paused | Yes (Resume) |
| Paused | Resume | Active | Yes |
| Active/Paused | Revoke | Revoked | No |
| Active | Time Expires | Expired | No |
| Paused | Time Expires | Expired | No |
struct Mandate {
address owner; // User granting the mandate
address spender; // App/protocol authorized to pull funds
address token; // ERC-20 token address (e.g., USDC on Base)
uint256 perChargeLimit; // Max amount per single debit
uint256 totalLimit; // Cumulative spending cap
uint256 spent; // Amount already pulled
uint256 cooldownSeconds; // Minimum time between debits
uint256 lastDebitAt; // Timestamp of last successful pull
uint256 startTime; // Mandate activation timestamp
uint256 endTime; // Mandate expiration timestamp
MandateStatus status; // Current status (Active/Paused/Revoked/Expired)
uint256 createdAt; // Creation timestamp
uint256 updatedAt; // Last modification timestamp
}function createMandate(
address spender,
address token,
uint256 perChargeLimit,
uint256 totalLimit,
uint256 cooldownSeconds,
uint256 startTime,
uint256 endTime
) external returns (uint256 mandateId)Requirements:
msg.sender(owner) andspendermust be non-zero and differenttokenmust be non-zeroperChargeLimit > 0andtotalLimit > 0perChargeLimit <= totalLimitstartTime < endTime- If
startTimeis in the past, it is automatically adjusted toblock.timestamp
function updateMandateLimits(
uint256 mandateId,
uint256 newPerChargeLimit,
uint256 newTotalLimit
) externalRequirements:
- Only the mandate owner can update limits
- Mandate must not be revoked or expired
newPerChargeLimit <= newTotalLimitnewTotalLimit >= spent(cannot reduce total limit below already-spent amount)
function pauseMandate(uint256 mandateId) external
function resumeMandate(uint256 mandateId) externalRequirements:
- Only the mandate owner can pause/resume
- Cannot pause a revoked or expired mandate
- Cannot pause an already-paused mandate
- Cannot resume an already-active mandate
function revokeMandate(uint256 mandateId) externalRequirements:
- Only the mandate owner can revoke
- Cannot revoke an already-revoked mandate
- Revocation is permanent and irreversible
function getMandate(uint256 mandateId) external view returns (...)Returns all mandate details. The status field is computed dynamically:
- If
block.timestamp > endTimeand status is not Revoked, returnsExpired - Otherwise, returns the stored status
- Owner-Only Operations:
updateMandateLimits,pauseMandate,resumeMandate,revokeMandatecan only be called by the mandate owner - Admin-Only Operations: UUPS upgrade authorization restricted to contract owner
- Public Read Access:
getMandateis publicly accessible for transparency
All state-changing functions (createMandate, updateMandateLimits, revokeMandate, pauseMandate, resumeMandate) are protected by OpenZeppelin's ReentrancyGuardUpgradeable.
- Uses
__gapstorage slots to allow for future upgrades without storage collisions - UUPS proxy pattern ensures implementation logic can be upgraded while preserving state
// User grants SubscriptionService permission to charge $10 USDC monthly
createMandate({
spender: subscriptionServiceAddress,
token: USDC_BASE_MAINNET, // 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
perChargeLimit: 10e6, // $10 USDC (6 decimals)
totalLimit: 120e6, // $120 total (12 months)
cooldownSeconds: 28 days, // Minimum 28 days between charges
startTime: block.timestamp,
endTime: block.timestamp + 365 days
});// User grants CloudService permission for flexible usage charges
createMandate({
spender: cloudServiceAddress,
token: USDC_BASE_MAINNET,
perChargeLimit: 50e6, // Max $50 per charge
totalLimit: 500e6, // $500 total budget
cooldownSeconds: 1 hours, // Can charge hourly
startTime: block.timestamp,
endTime: block.timestamp + 90 days
});// User grants Escrow permission for 3 milestone payments
createMandate({
spender: escrowAddress,
token: USDC_BASE_MAINNET,
perChargeLimit: 1000e6, // $1000 per milestone
totalLimit: 3000e6, // $3000 total (3 milestones)
cooldownSeconds: 7 days, // Minimum 7 days between milestones
startTime: block.timestamp,
endTime: block.timestamp + 180 days
});-
User Authorization Flow:
- Present user with clear mandate parameters (limits, duration, cooldown)
- User calls
createMandate()from their wallet - Store
mandateIdin your backend for future reference
-
Executing Charges (future functionality in Block 2):
- Verify mandate is active:
getMandate(mandateId)returns status == Active - Check spending limits:
spent + chargeAmount <= totalLimit - Check cooldown:
block.timestamp >= lastDebitAt + cooldownSeconds - Execute debit transaction
- Verify mandate is active:
-
Handling Edge Cases:
- Always check mandate status before attempting a charge
- Handle
MandateExpired,MandatePaused,MandateRevokederrors gracefully - Notify users when mandates are approaching limits or expiration
-
Display Mandate Details:
const mandate = await contract.getMandate(mandateId); const remaining = mandate.totalLimit - mandate.spent; const daysLeft = (mandate.endTime - Date.now() / 1000) / 86400;
-
User Controls:
- Provide UI for pausing/resuming mandates
- Show revoke button with clear warning about irreversibility
- Display usage history and remaining limits
-
Status Indicators:
- Active: Green indicator, show next charge availability
- Paused: Yellow indicator, offer resume action
- Expired/Revoked: Gray indicator, mark as inactive
- Mandate data is stored in a single
mapping(uint256 => Mandate)for efficient lookup - Status computation for expiration is done in view functions (no gas cost)
- Uses
via_ircompilation for stack depth optimization - Events emitted for all state changes to enable efficient off-chain indexing
The MandateV1 contract uses the UUPS (Universal Upgradeable Proxy Standard) pattern:
- Implementation can be upgraded by contract owner
- Storage layout preserved via
__gaparrays - All upgrades must maintain backward compatibility with existing mandate data
Base Sepolia (Testnet): Chain ID 84532 Base Mainnet: Chain ID 8453
Mandates are designed specifically for Base L2, leveraging low gas costs and fast finality for optimal recurring payment experiences.
- User Sovereignty: Users retain full control over their mandates
- Transparent Limits: All constraints are enforced on-chain and publicly auditable
- Immutable History: All mandate actions emit events for permanent record
- No Custody: LoopPay mandates are permission layers only; no funds are held in escrow