Timelocks and Execution Delays: Developer Reference

Contract-level reference for configuring and reading timelock parameters.

This document provides a deep technical breakdown of the implementation, contract interactions, state machine, and programmatic execution of timelocks within the IPOR Fusion protocol. It is intended for smart contract engineers, audit teams, and automation developers. For high-level conceptual guidance, refer to the Curator Manual.

1. Smart Contract Architecture

The timelock framework in IPOR Fusion is built natively into IporFusionAccessManager, which extends and customizes OpenZeppelin's AccessManager contract.

Every restricted function across the IPOR Fusion ecosystem (including PlasmaVault, PlasmaVaultGovernance, and market connectors) is decorated with the onlyAuthorized modifier. This modifier queries the IporFusionAccessManager to verify if the caller is authorized and whether they must satisfy an execution delay.

2. The Transaction Lifecycle State Machine

A scheduled operation transitions through four distinct state phases inside IporFusionAccessManager:

          ┌──────────────┐
          │    Unset     │
          └──────┬───────┘
                 │ schedule() called
                 ▼
          ┌──────────────┐
          │   Pending    │ ◄─── block.timestamp < scheduledTime
          └──────┬───────┘
                 │ block.timestamp >= scheduledTime
                 ▼
          ┌──────────────┐
          │    Ready     │ ─── cancel() called ──► [ Unset ]
          └──────┬───────┘
                 │ execute() called
                 ▼
          ┌──────────────┐
          │   Executed   │ (Removed from state / closed)
          └──────────────┘

The execution lifecycle of a delayed operation is mathematically bounded by:

Tscheduled=Tproposal+DdelayT_{scheduled} = T_{proposal} + D_{delay}

Where:

  • TproposalT_{proposal} is the block timestamp when schedule() is transactionally processed.
  • DdelayD_{delay} is the configured executionDelay of the calling account-role pairing.
  • TscheduledT_{scheduled} is the earliest timestamp at which execute() can successfully resolve.

3. Operation Hashing & Identification

Every scheduled transaction is tracked using a unique bytes32 digest called the operationId. The operationId is computed by hashing the components of the execution target:

operationId=keccak256(abi.encode(caller,target,data))\text{operationId} = \text{keccak256}(\text{abi.encode}(caller, target, data))

Where:

  • caller: The address initiating the action.
  • target: The address of the destination contract being called.
  • data: The exact encoded execution calldata (function selector + parameters).

This hashing mechanism guarantees that an authorized account can only execute the exact payload that was originally audited and scheduled. Any alteration to the calldata parameters will result in a completely different operationId and fail verification.

4. Programmatic Administration: Set, Edit, and Remove

Developers can programmatically manage individual timelocks or set system floors through the access manager's admin interface. Because these actions are protected, they are subject to any execution delays assigned to the administrator address itself.

A. Setting or Editing an Individual Delay

To set or edit a delay on a specific account, call grantRole with the target executionDelay:

function grantRole(
    uint64 roleId, 
    address account, 
    uint32 executionDelay
) external;
  • Behavior:
    • If the account does not yet possess roleId, the role is granted with the specified executionDelay.
    • If the account already possesses roleId, calling this updates their individual execution delay to the new value.
    • Emits: RoleGranted(roleId, account, executionDelay, newMember)

B. Removing an Individual Timelock

To remove a timelock entirely (enabling instant execution), grant the role with a delay of 0:

accessManager.grantRole(Roles.ATOMIST_ROLE, operatorAddress, 0);
  • Result: operatorAddress can now directly call Atomist functions on target contracts without going through the schedule() workflow.

C. Restricting Bypasses via Minimal Role Delays

To establish a global safety floor that prevents any administrator from granting zero-delay configurations, set a minimal delay floor:

function setMinimalExecutionDelaysForRoles(
    uint64[] calldata roleIds, 
    uint32[] calldata delays
) external;
  • Validation: Once configured, any subsequent call to grantRole() that attempts to set an executionDelay lower than the registered floor will transactionally revert with the custom error TooShortExecutionDelayForRole(roleId, executionDelay).
  • Emits: MinimalExecutionDelaySet(roleId, delay) per updated role.

5. Technical API Reference

A. Scheduling an Operation

To propose a transaction when subject to a timelock, an account must invoke schedule:

function schedule(
    address target, 
    bytes calldata data, 
    uint48 when
) external returns (bytes32 operationId, uint32 delay);

  • Parameters:
    • target: The destination contract address.
    • data: The full encoded payload of the transaction to execute.
    • when: The intended execution timestamp. This value must be equal to or greater than block.timestamp+Ddelayblock.timestamp + D_{delay}.
  • Returns:
    • operationId: The unique cryptographic hash identifying the proposal.
    • delay: The exact delay (in seconds) that must elapse before execution.

B. Executing a Scheduled Operation

Once the delay period has passed, any account can trigger the execution of the scheduled operation by calling execute:

function execute(
    address target, 
    bytes calldata data
) external payable returns (bytes memory);

  • Execution Constraints:
    • Reverts with AccessManagerNotReady(operationId) if block.timestamp<Tscheduledblock.timestamp < T_{scheduled}.
    • Reverts with AccessManagerExpired(operationId) if the current time exceeds the expiration threshold (Tscheduled+PERIOD_OF_GRACET_{scheduled} + \text{PERIOD\_OF\_GRACE}, where the standard Grace Period is typically 5 days5\text{ days}).
    • Executes the payload using a low-level solidity call. If the target reverts, execute bubbles up the revert data.

C. Cancelling an Operation

Authorized role administrators or accounts holding the GUARDIAN_ROLE can wipe a pending operation from state:

function cancel(
    address target, 
    bytes calldata data
) external returns (uint32);
  • Behavior: Clears the execution timestamp of the operationId from the storage mapping, reverting its status to Unset. A cancelled operation can never be executed. To run the transaction again, it must be scheduled anew.
  • Security Guard (Cancel Semantics): AM.cancel succeeds only for the scheduler, a member of the ADMIN_ROLE, or a member of the role returned by getRoleGuardian(getTargetFunctionRole(target, selector)).
  • CRITICAL LIMITATION: This creates two distinct behaviors in the Fusion Access Manager:
    • Vault Configuration Targets (Cancelable): For functions on the Vault (e.g., addFuses), initialization assigns GUARDIAN as the role guardian in the function mapping. Thus, Guardians can cancel these.
    • Access Manager Targets (Not Cancelable by Guardian): Role management calls (grantRole, revokeRole, setGrantDelay) are targeted at the Access Manager itself. These selectors are not in the custom function mapping, so getTargetFunctionRole returns the default OpenZeppelin ADMIN_ROLE, whose role guardian is unset.
    • Conclusion: Once ADMIN is renounced, a scheduled permission change can be cancelled only by the account that scheduled it.

D. Enforcing Safety Floors

The access manager supports establishing minimum execution delays on a per-role basis to prevent administrative oversight or fast-path exploit pathing:

function setMinimalExecutionDelaysForRoles(
    uint64[] calldata roleIds, 
    uint32[] calldata delays
) external;

  • Constraints:
    • roleIds and delays arrays must match in length.
    • Once configured, calling grantRole() with an execution delay below the specified floor will revert.

6. Security Architecture Notes

Self-Referential Timelocks (Admin Constraint)

The administrative capabilities of the IporFusionAccessManager are completely governed by its own permission rules. If an administrative account (e.g., OWNER_ROLE) has an individual delay, it is strictly bound by that delay when performing administrative actions:

// This call will fail if the owner attempts immediate execution:
accessManager.grantRole(Roles.ATOMIST_ROLE, newAtomist, 0);

// Instead, the delayed owner MUST call:
accessManager.schedule(
    address(accessManager),
    abi.encodeWithSelector(accessManager.grantRole.selector, Roles.ATOMIST_ROLE, newAtomist, 0),
    block.timestamp + ownerDelay
);

Reentrancy & Context Preservation

  • To prevent exploitation of state checks, execute() implements a reentrancy guard or execution lock during the execution of the call.
  • The access manager preserves the execution context using msg.sender routing. When a target contract receives a call, it verifies that msg.sender is the IporFusionAccessManager address, and reads the originating caller context.

7. Code Examples & Scripts

Scenario: Programmatic Scheduling and Execution of a Performance Fee Update

Below is a hardhat/ethers-style script demonstrating how an automation bot or multisig relayer interacts with the timelocked workflow to update performance fees on a PlasmaVault via PlasmaVaultGovernance.

import { ethers } from "hardhat";

async function main() {
  const [operator] = await ethers.getSigners();

  // Addresses of deployed contracts
  const accessManagerAddress = "0x...AccessManagerAddress";
  const governanceAddress = "0x...PlasmaVaultGovernanceAddress";
  const vaultAddress = "0x...PlasmaVaultAddress";

  // Get contract instances
  const accessManager = await ethers.getContractAt("IporFusionAccessManager", accessManagerAddress);
  const governance = await ethers.getContractAt("PlasmaVaultGovernance", governanceAddress);

  // 1. Prepare the exact calldata payload for the target execution
  const feeAccount = "0x...FeeCollectorAddress";
  const performanceFeeBasisPoints = 1500; // 15.00% performance fee
  
  const targetCalldata = governance.interface.encodeFunctionData("configurePerformanceFee", [
    vaultAddress,
    performanceFeeBasisPoints
  ]);

  console.log("Preparing to schedule fee update...");

  // 2. Fetch the current execution delay of the operator for this role
  const executionDelay = await accessManager.getExecutionDelay(operator.address);
  const blockNumber = await ethers.provider.getBlockNumber();
  const currentBlock = await ethers.provider.getBlock(blockNumber);
  const executionTime = currentBlock.timestamp + Number(executionDelay);

  // 3. Schedule the transaction
  const scheduleTx = await accessManager.schedule(
    governanceAddress,
    targetCalldata,
    executionTime
  );
  const receipt = await scheduleTx.wait();

  // Extract the operationId from the Scheduled event
  const event = receipt.events?.find((e) => e.event === "OperationScheduled");
  const operationId = event?.args?.operationId;
  console.log(`Successfully scheduled. Operation ID: ${operationId}`);
  console.log(`Execution is locked until block timestamp: ${executionTime}`);

  // 4. Fast-forward simulation (Wait Phase)
  // In a real environment, you would wait executionDelay seconds.
  // In tests, you can fast-forward the EVM:
  // await ethers.provider.send("evm_increaseTime", [Number(executionDelay)]);
  // await ethers.provider.send("evm_mine", []);

  // 5. Execute the transaction after the delay has passed
  console.log("Execution delay satisfied. Initiating execution...");
  const executeTx = await accessManager.execute(
    governanceAddress,
    targetCalldata
  );
  await executeTx.wait();
  console.log("Fee configuration successfully executed!");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

8. Enabling Timelocks: Canonical Call Order

Enabling timelocks on an existing vault is a sequence of permission updates on the existing IporFusionAccessManager. No funds move and no redeployment is required.

Batch vs. Step-by-Step Execution: Executing all calls atomically in a single batch (via a governance multisig) is highly recommended. A wrong order in a batch reverts everything harmlessly. If executing sequentially, you must finish everything a role must do before raising that role's delay, otherwise you lock the remainder of your setup sequence behind the timelock you just created.

The canonical call order is load-bearing and must be executed as follows:

  1. Guardians: grantRole(2, <account>, 0) for each new guardian, then revokeRole(2, <old key>). Do this first as they are the emergency brake.
  2. Migration (if needed): Grant every governance role to the target multisig at a 0 delay (raised later). Revoke the old account afterwards (with ATOMIST last, since it administers other roles).
  3. Grant Delays: While ADMIN still has members, call setGrantDelay (e.g., 14 days for ADMIN/OWNER, 3 days for governance tier). Note: These arm only at T+5 days due to the OpenZeppelin 5-day minSetback.
  4. Floors: Call setMinimalExecutionDelaysForRoles on the vault (e.g., 14 days for OWNER, 3 days for ATOMIST and FUSE_MANAGER).
  5. Re-grants (CRITICAL): Setting a floor is not retroactive. It does not magically update the delays of accounts that already hold those roles. Every existing membership must be explicitly re-granted its role to apply the target delay, bottom-up (3-day tier first, then ATOMIST, then OWNER at 14 days). Raising delays takes effect immediately.
  6. Self-Admin and Renounce: Call setRoleAdmin(1, 1) to repoint the admin of OWNER to OWNER itself. Finally, call renounceRole(0, <account>) as the final call.

Developer Note: Never renounce ADMIN without repointing OWNER first, or OWNER membership and delays will freeze permanently.

9. Layout Variants and Post-Execution Verification

Because the entire configuration lives onchain, the timelock state must be verified post-execution.

Layout Variants

Depending on when the vault was deployed, the starting state dictates the available protections:

  • Factory (Current): ADMIN has no members, and OWNER is already self-administered. The rollout skips grant delays and the renounce steps.
  • Legacy (Live ADMIN): Governance multisig holds ADMIN. Requires the full sequence including setGrantDelay and renouncing.
  • Legacy (Dead ADMIN): ADMIN is renounced, but getRoleAdmin(OWNER) == ADMIN. Warning: This offers partial protection. The OWNER delay can never be raised and grant delays are unreachable. The complete fix requires migrating to a new vault.

Last updated