diff --git a/tips/tip-1011.md b/tips/tip-1011.md index 9615c6e2b9..fabd426130 100644 --- a/tips/tip-1011.md +++ b/tips/tip-1011.md @@ -12,7 +12,7 @@ protocolVersion: TBD (requires hardfork) ## Abstract -This TIP extends Access Keys with two new permission features: (1) **periodic spending limits** that automatically reset after a configurable time period, enabling subscription-based access patterns, and (2) **call scoping** that restricts keys to only interact with specific contract addresses and/or specific function selectors on those contracts. +This TIP extends Access Keys with two new permission features: (1) **periodic spending limits** that automatically reset after a configurable time period, enabling subscription-based access patterns, and (2) **call scoping** that restricts keys to only interact with specific contract addresses and/or specific function selectors on those contracts. Additionally, it introduces a **period anchor** for calendar-aligned resets. ## Motivation @@ -31,6 +31,7 @@ The existing `TokenLimit` specifies a one-time spending cap that depletes perman 2. **Recurring donations**: Allow an NPO to withdraw up to 5 USDC weekly 3. **Payroll systems**: Enable payroll contracts to transfer salaries monthly 4. **Rate-limited APIs**: Authorize API access keys with per-period token budgets +5. **Card programs**: Enforce daily/monthly velocity limits that reset on calendar boundaries (e.g., midnight UTC, 1st of month) ### Call Scoping (Destination + Function Selector) @@ -73,9 +74,21 @@ struct TokenLimit { uint256 remainingInPeriod; // Remaining allowance in current period uint64 period; // Period duration in seconds (0 = one-time limit) uint64 periodEnd; // Timestamp when current period expires + uint64 periodAnchor; // Anchor timestamp for calendar-aligned resets (0 = relative mode) } ``` +**Period anchor semantics:** +- `periodAnchor = 0` (default): Relative mode. Period starts from first spend, advances by whole multiples of `period` on reset. This is the default behavior and preserves backward compatibility. +- `periodAnchor != 0`: Calendar-aligned mode. The anchor is a fixed UTC timestamp (e.g., `2025-03-01T00:00:00Z`) and `periodEnd` always snaps to the next multiple of `period` from the anchor. This ensures resets occur on deterministic calendar boundaries regardless of when transactions land. + +**Example — monthly limit resetting on the 1st:** +``` +periodAnchor = 1740787200 // 2025-03-01T00:00:00Z +period = 2592000 // 30 days in seconds +``` +If a charge lands March 3rd but the period ended March 1st, `periodEnd` snaps to April 1st — not "March 3rd + 30 days." + ### CallScope A new struct for specifying address+selector restrictions: @@ -152,11 +165,16 @@ function verifyAndUpdateSpending(key, token, amount): if limit.period > 0: // Periodic limit if block.timestamp >= limit.periodEnd: - // Reset period — advance periodEnd by whole multiples of period - // to prevent drift when transactions land late - elapsed = block.timestamp - limit.periodEnd - periodsElapsed = elapsed / limit.period + 1 - limit.periodEnd += periodsElapsed * limit.period + if limit.periodAnchor > 0: + // Calendar-aligned mode: snap to next anchor-aligned boundary + elapsed = block.timestamp - limit.periodAnchor + periodsElapsed = elapsed / limit.period + 1 + limit.periodEnd = limit.periodAnchor + (periodsElapsed * limit.period) + else: + // Relative mode: advance from last periodEnd + elapsed = block.timestamp - limit.periodEnd + periodsElapsed = elapsed / limit.period + 1 + limit.periodEnd += periodsElapsed * limit.period limit.remainingInPeriod = limit.limit // Reset to per-period allowance if amount > limit.remainingInPeriod: @@ -227,7 +245,8 @@ TokenLimit := RLP([ limit: uint256, remainingInPeriod: uint256, period: uint64, - periodEnd: uint64 + periodEnd: uint64, + periodAnchor: uint64 ]) CallScope := RLP([ @@ -246,9 +265,9 @@ This TIP requires a **hardfork** due to changes in transaction encoding and exec ### TokenLimit (2 fields → 5 fields) -The current `TokenLimit` struct encodes as `[token, limit]`. This TIP extends it to `[token, limit, remainingInPeriod, period, periodEnd]`. +The current `TokenLimit` struct encodes as `[token, limit]`. This TIP extends it to `[token, limit, remainingInPeriod, period, periodEnd, periodAnchor]`. -**Breaking change**: Old nodes cannot decode new transactions with 5-field `TokenLimit`. New nodes must implement version-tolerant decoding: +**Breaking change**: Old nodes cannot decode new transactions with 6-field `TokenLimit`. New nodes must implement version-tolerant decoding: ``` On decode: @@ -257,14 +276,19 @@ On decode: remainingInPeriod = limit period = 0 periodEnd = 0 + periodAnchor = 0 else if list.len() == 5: - // V2 (periodic limit) + // V2 (periodic limit, relative mode only) + decode all fields + periodAnchor = 0 + else if list.len() == 6: + // V3 (periodic limit with optional anchor) decode all fields else: error ``` -Post-fork, all new `TokenLimit` encodings MUST use the 5-field format for consistency. +Post-fork, all new `TokenLimit` encodings MUST use the 6-field format for consistency. ### KeyAuthorization (trailing field addition) @@ -303,6 +327,7 @@ This TIP requires additional per-token state for periodic limits. **Additive sto | `spending_limit_max[key][token]` | `U256` | Per-period cap (new) | | `spending_limit_period[key][token]` | `u64` | Period duration in seconds (new) | | `spending_limit_period_end[key][token]` | `u64` | Current period end timestamp (new) | +| `spending_limit_period_anchor[key][token]` | `u64` | Anchor timestamp for calendar-aligned resets; 0 = relative (new) | For call scoping: | Mapping | Type | Description | @@ -317,10 +342,10 @@ Legacy keys (pre-fork) have `period = 0`, `max = 0`, and behave as one-time limi The following MUST be gated behind the hardfork activation: -1. **RLP decoding**: Accept 5-field `TokenLimit` and `allowedCalls` in `KeyAuthorization` -2. **Periodic limit reset logic**: Check `periodEnd` and reset `remainingInPeriod` on spend +1. **RLP decoding**: Accept 5/6-field `TokenLimit` and `allowedCalls` in `KeyAuthorization` +2. **Periodic limit reset logic**: Check `periodEnd` and reset `remainingInPeriod` on spend, with anchor-aware reset for calendar-aligned periods 3. **Call scoping enforcement**: Validate transaction `to` and calldata against `allowedCalls` -4. **New precompile storage writes**: Write to new storage slots for period/call scope data +4. **New precompile storage writes**: Write to new storage slots for period/anchor/call scope data 5. **New precompile interface methods**: `getAllowedCalls()`, updated `getRemainingLimit()` return type Pre-fork blocks MUST be replayed with old semantics to preserve state root consistency. @@ -343,17 +368,20 @@ Pre-fork blocks MUST be replayed with old semantics to preserve state root consi ## Test Cases -1. **Periodic reset**: Verify that a periodic limit resets correctly after the period elapses -2. **Partial period usage**: Verify that unused periodic allowance does not roll over -3. **Call scope allow (address+selector)**: Verify that calls matching (target, selector) succeed -4. **Call scope allow (address-only)**: Verify that any function on an allowed address succeeds when `selector=0` -5. **Call scope allow (selector-only)**: Verify that allowed selector on any address succeeds when `target=0` -6. **Call scope deny**: Verify that calls not matching any allowed scope revert -7. **Empty allowed calls**: Verify that empty `allowedCalls` allows any call -8. **Mixed limits**: Verify that a key can have both one-time and periodic limits for different tokens -9. **Upgrade path**: Verify that existing keys continue to function after upgrade -10. **Batch call validation**: Verify that all calls in a batch transaction are validated against allowed scopes -11. **ETH transfer with call scoping**: Verify that ETH transfers (empty calldata) work correctly with `selector=0` scopes +1. **Periodic reset (relative)**: Verify that a periodic limit with `periodAnchor=0` resets correctly after the period elapses +2. **Periodic reset (anchored)**: Verify that a periodic limit with `periodAnchor!=0` snaps `periodEnd` to the next anchor-aligned boundary +3. **Anchor drift resistance**: Verify that a late transaction (e.g., day 3 of a monthly period) still resets to the 1st of next month, not "now + 30 days" +4. **Partial period usage**: Verify that unused periodic allowance does not roll over +5. **Call scope allow (address+selector)**: Verify that calls matching (target, selector) succeed +6. **Call scope allow (address-only)**: Verify that any function on an allowed address succeeds when `selector=0` +7. **Call scope allow (selector-only)**: Verify that allowed selector on any address succeeds when `target=0` +8. **Call scope deny**: Verify that calls not matching any allowed scope revert +9. **Empty allowed calls**: Verify that empty `allowedCalls` allows any call +10. **Mixed limits**: Verify that a key can have both one-time and periodic limits for different tokens +11. **Upgrade path**: Verify that existing keys continue to function after upgrade +12. **Batch call validation**: Verify that all calls in a batch transaction are validated against allowed scopes +13. **ETH transfer with call scoping**: Verify that ETH transfers (empty calldata) work correctly with `selector=0` scopes +14. **V2→V3 decoding**: Verify that 5-field `TokenLimit` (no anchor) decodes with `periodAnchor=0` ## References