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:
Where:
- is the block timestamp when
schedule()is transactionally processed. - is the configured
executionDelayof the calling account-role pairing. - 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:
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 specifiedexecutionDelay. - If the account already possesses
roleId, calling this updates their individual execution delay to the new value. - Emits:
RoleGranted(roleId, account, executionDelay, newMember)
- If the account does not yet possess
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:
operatorAddresscan now directly call Atomist functions on target contracts without going through theschedule()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 anexecutionDelaylower than the registered floor will transactionally revert with the custom errorTooShortExecutionDelayForRole(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 .
- 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 . - Reverts with
AccessManagerExpired(operationId)if the current time exceeds the expiration threshold (, where the standard Grace Period is typically ). - Executes the payload using a low-level solidity
call. If the target reverts,executebubbles up the revert data.
- Reverts with
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
operationIdfrom the storage mapping, reverting its status toUnset. A cancelled operation can never be executed. To run the transaction again, it must be scheduled anew. - Security Guard (Cancel Semantics):
AM.cancelsucceeds only for the scheduler, a member of theADMIN_ROLE, or a member of the role returned bygetRoleGuardian(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 assignsGUARDIANas 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, sogetTargetFunctionRolereturns the default OpenZeppelinADMIN_ROLE, whose role guardian is unset. - Conclusion: Once
ADMINis renounced, a scheduled permission change can be cancelled only by the account that scheduled it.
- Vault Configuration Targets (Cancelable): For functions on the Vault (e.g.,
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:
roleIdsanddelaysarrays 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.senderrouting. When a target contract receives a call, it verifies thatmsg.senderis theIporFusionAccessManageraddress, 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:
- Guardians:
grantRole(2, <account>, 0)for each new guardian, thenrevokeRole(2, <old key>). Do this first as they are the emergency brake. - Migration (if needed): Grant every governance role to the target multisig at a
0delay (raised later). Revoke the old account afterwards (withATOMISTlast, since it administers other roles). - Grant Delays: While
ADMINstill has members, callsetGrantDelay(e.g.,14 daysforADMIN/OWNER,3 daysfor governance tier). Note: These arm only at T+5 days due to the OpenZeppelin 5-dayminSetback. - Floors: Call
setMinimalExecutionDelaysForRoleson the vault (e.g., 14 days forOWNER, 3 days forATOMISTandFUSE_MANAGER). - 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, thenOWNERat 14 days). Raising delays takes effect immediately. - Self-Admin and Renounce: Call
setRoleAdmin(1, 1)to repoint the admin ofOWNERtoOWNERitself. Finally, callrenounceRole(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):
ADMINhas no members, andOWNERis already self-administered. The rollout skips grant delays and the renounce steps. - Legacy (Live ADMIN): Governance multisig holds
ADMIN. Requires the full sequence includingsetGrantDelayand renouncing. - Legacy (Dead ADMIN):
ADMINis renounced, butgetRoleAdmin(OWNER) == ADMIN. Warning: This offers partial protection. TheOWNERdelay can never be raised and grant delays are unreachable. The complete fix requires migrating to a new vault.