59335 sc low periodattimestamp function returns current period instead of queried period leading to temporary freezing of funds
Submitted on Nov 11th 2025 at 08:59:36 UTC by @legion for Audit Comp | Firelight
- Report ID: #59335
- Report Type: Smart Contract
- Report severity: Low
- Target: https://github.com/firelight-protocol/firelight-core/blob/main/contracts/FirelightVault.sol
- Impacts:
- Temporary freezing of funds
Description
Brief/Intro
The periodAtTimestamp(uint48 timestamp) view function in FirelightVault.sol contains a critical logic error where it always returns the current period number regardless of the timestamp parameter passed to it. This function is designed to allow users and off-chain systems to determine which period corresponds to any given historical or future timestamp. However, due to incorrect use of Time.timestamp() instead of the function parameter in the calculation, all queries return the same value—the currently active period. This breaks withdrawal scheduling automation, historical analytics, and user-facing dashboards, potentially causing users' mature withdrawals to remain temporarily frozen beyond their intended claim window because automated systems cannot correctly determine when periods end.
Vulnerability Details
Root Cause
The vulnerability exists in the periodAtTimestamp function at line 246-250 of FirelightVault.sol:
function periodAtTimestamp(uint48 timestamp) public view returns (uint256) {
PeriodConfiguration memory periodConfiguration = periodConfigurationAtTimestamp(timestamp);
// solhint-disable-next-line max-line-length
return periodConfiguration.startingPeriod + _sinceEpoch(periodConfiguration.epoch) / periodConfiguration.duration;
}
The function correctly retrieves the appropriate PeriodConfiguration for the given timestamp parameter using periodConfigurationAtTimestamp(timestamp). However, it then calculates the elapsed time by calling the private helper function _sinceEpoch:
function _sinceEpoch(uint48 epoch) private view returns (uint48) {
return Time.timestamp() - epoch;
}
The _sinceEpoch function always uses Time.timestamp() (the current block timestamp) rather than the timestamp parameter passed to periodAtTimestamp. This means the elapsed time calculation is always based on the current moment, not the queried timestamp.
Concrete Example
Consider a vault deployed with a 1-week (604800 seconds) period duration:
Timeline:
- Deploy time (T₀): timestamp = 1,000,000
- Current time (T_now): timestamp = 4,024,000 (5 weeks later, period = 5)
Expected behavior:
periodAtTimestamp(1,000,000)→ should return0(period at deploy)periodAtTimestamp(2,209,600)→ should return2(period after 2 weeks)periodAtTimestamp(7,048,000)→ should return10(future period)
Actual behavior:
periodAtTimestamp(1,000,000)→ returns5(current period)periodAtTimestamp(2,209,600)→ returns5(current period)periodAtTimestamp(7,048,000)→ returns5(current period)
All three calls return the same value despite querying different timestamps.
Impact Detail
Temporary Freezing of Funds The FirelightVault implements an asynchronous withdrawal system where users call withdraw() or redeem() to queue a withdrawal for the next period (currentPeriod() + 1). Once that period ends, users can call claimWithdraw(period) to receive their funds. The claimWithdraw function requires that period < currentPeriod(), meaning the period must have ended. This can lead to : Broken off-chain automation: Dashboards, bots, and integrations rely on periodAtTimestamp() to calculate:
- "When will my withdrawal become claimable?"
- "What period will be active at timestamp X?"
- Historical period lookups for analytics and auditing
References
- Vulnerable function:
FirelightVault.sol#L246-250-periodAtTimestamp() - Root cause:
FirelightVault.sol#L794-796-_sinceEpoch() - Related functions correctly using
_sinceEpochfor current time:currentPeriodStart()- Line 274currentPeriodEnd()- Line 282
Proof of Concept
Proof of Concept
const { loadFixture, time } = require('@nomicfoundation/hardhat-network-helpers')
const { deployVault } = require('./setup/fixtures.js')
const { expect } = require('chai')
const { ethers } = require('hardhat')
describe("periodAtTimestamp bug verification", function () {
let token_contract, firelight_vault, admin, users, utils, config;
const DECIMALS = 6;
const PERIOD_DURATION = 604800; // 1 week (from fixtures.js default)
before(async function () {
({ token_contract, firelight_vault, limit_updater, users, utils, config, admin } = await loadFixture(
deployVault.bind(null, { decimals: DECIMALS, initial_deposit_limit: ethers.parseUnits('1000000', DECIMALS) })
));
});
it("should demonstrate that periodAtTimestamp ignores its timestamp argument", async function () {
const deployTimestamp = await time.latest();
// Current period should be 0 at deploy
const currentPeriod = await firelight_vault.currentPeriod();
console.log("Current period:", currentPeriod.toString());
// Fast forward 5 weeks (5 periods)
await time.increase(5 * PERIOD_DURATION);
const newCurrentPeriod = await firelight_vault.currentPeriod();
console.log("Current period after 5 weeks:", newCurrentPeriod.toString());
// Query for period at deploy time (should be 0)
const periodAtDeploy = await firelight_vault.periodAtTimestamp(deployTimestamp);
console.log("Period at deploy timestamp (should be 0):", periodAtDeploy.toString());
// Query for period 2 weeks after deploy (should be 2)
const twoWeeksAfter = deployTimestamp + (2 * PERIOD_DURATION);
const periodAt2Weeks = await firelight_vault.periodAtTimestamp(twoWeeksAfter);
console.log("Period at 2 weeks after deploy (should be 2):", periodAt2Weeks.toString());
// Query for a future time (should be in the future, but returns current)
const futureTime = (await time.latest()) + (10 * PERIOD_DURATION);
const periodAtFuture = await firelight_vault.periodAtTimestamp(futureTime);
console.log("Period at future timestamp (should be > current):", periodAtFuture.toString());
// BUG: All three calls should return different values but they all return the current period
console.log("\nBUG VERIFICATION:");
console.log("All three queries return current period:", newCurrentPeriod.toString());
console.log("periodAtDeploy == currentPeriod?", periodAtDeploy === newCurrentPeriod);
console.log("periodAt2Weeks == currentPeriod?", periodAt2Weeks === newCurrentPeriod);
console.log("periodAtFuture == currentPeriod?", periodAtFuture === newCurrentPeriod);
// This demonstrates the bug: regardless of the timestamp argument,
// periodAtTimestamp always returns the current period
expect(periodAtDeploy).to.equal(newCurrentPeriod);
expect(periodAt2Weeks).to.equal(newCurrentPeriod);
expect(periodAtFuture).to.equal(newCurrentPeriod);
});
});