Guardian's review of Escrow for VectorSkyTree, published April 2026. The report records 8 findings, including 3 medium and 4 low.
- Published
- Review window
- April 1 to 2, 2026
- Language
- Solidity
- Sector
- Payments
- 0 Critical
- 0 High
- 3 Medium
- 4 Low
- 1 Informational
Findings 8
-
M-01 Medium Yearly increment change rewrites accrued cap Logical Error Acknowledged
Description
executeYearlyIncrementChangeupdatesyearlyIncrementUsdimmediately, but the contract only settles elapsed yearly growth later inside_applyYearlyIncrements. This means already-accrued time is repriced at the new increment rate when the nextclaimoccurs instead of at the rate that was active while the time accrued.function executeYearlyIncrementChange(uint256 _yearlyIncrementUsd) external onlyAdmin { _execute(keccak256(abi.encode("yearlyIncrement", _yearlyIncrementUsd))); yearlyIncrementUsd = _yearlyIncrementUsd; // new rate is stored immediately emit YearlyIncrementChanged(_yearlyIncrementUsd); } function _applyYearlyIncrements() internal { if (yearlyIncrementUsd == 0) return; uint256 yrs = (block.timestamp - lastIncrementTimestamp) / 365 days; if (yrs > 0) { maxPeriodUsd += yrs * yearlyIncrementUsd; // elapsed years use the latest rate lastIncrementTimestamp += yrs * 365 days; emit YearlyIncrementApplied(maxPeriodUsd, yrs); } }Impact:
- Raising
yearlyIncrementUsdcan retroactively over-credit the claimant for past years, which can be gamed by delayingclaim. - Lowering
yearlyIncrementUsdcan claw back claim capacity that had already accrued economically but was not yet materialized on-chain. - Setting
yearlyIncrementUsdto0can wipe out all previously accrued but unsettled yearly growth.
Example:
maxPeriodUsdstarts at$1,000andyearlyIncrementUsdstarts at$100.- Three full years pass without a
claim, so under the original rate the cap would have grown to$1,300. - Before the claimant makes the next
claim, the admin executesexecuteYearlyIncrementChangewith$200. - The claimant waits until after that update to submit the delayed
claim. _applyYearlyIncrementsnow applies3 * $200, so the cap becomes$1,600instead of$1,300.
The result is that the delayed config update changes the meaning of past elapsed time, not just future elapsed time.
Recommendation
Before writing a new
yearlyIncrementUsd, first settle any accrued growth under the old rate. One approach is to checkpoint the pending increase inexecuteYearlyIncrementChangebefore updating the variable. Another approach is to store an effective from timestamp so time before the change uses the old rate and time after the change uses the new rate. - Raising
-
M-02 Medium Min period change can extend live period Logical Error Acknowledged
Description
executeMinPeriodChangeupdatesminPeriodimmediately, and_rollPerioduses the currentminPeriodwhen deciding whether the current claim window has expired. Because of that, increasingminPeriodcan retroactively extend a period that would already have expired under the old setting.function executeMinPeriodChange(uint256 _minPeriod) external onlyAdmin { if (_minPeriod < 7 days) revert MinPeriodTooShort(); _execute(keccak256(abi.encode("minPeriod", _minPeriod))); minPeriod = _minPeriod; // applies to the current live period immediately emit MinPeriodSet(_minPeriod); } function _rollPeriod() internal { if (block.timestamp >= periodStart + minPeriod) { claimedThisPeriod = 0; periodStart = block.timestamp; } }Impact:
- The claimant can lose access to a fresh period even after the previous waiting window has already elapsed.
- The admin can delay the claimant’s next withdrawal by extending
minPeriodshortly before the claimant makes the first post-expiryclaim.
Example:
- The claimant uses the full
$1,000budget withminPeriod = 7 days. - After 7 days pass, the claimant should be eligible for a fresh period.
- Before the claimant calls
claim, the admin executesexecuteMinPeriodChangeto raiseminPeriodto30 days. _rollPeriodnow checksperiodStart + 30 days, so the contract still treats the old period as active and the newclaimreverts.
The result is that the delayed config update changes the status of the current period instead of only affecting future periods.
Recommendation
Make
minPeriodchanges take effect only for the next period boundary. For example, keep the current live period under the oldminPeriodand apply the new value only after_rollPeriodstarts a new period. Another option is to settle the current period at execution time and then start a new period using the new duration. -
M-03 Medium Same admin and claimant collapses controls Logical Error Acknowledged
Description
The constructor does not prevent
adminandclaimantfrom being set to the same address. When that happens, the contract's two-party approval model degrades because_callerBitreturnsADMIN_BITfirst, so the shared address can never contributeCLAIMANT_BIT.function _callerBit() internal view returns (uint8) { if (msg.sender == admin) return ADMIN_BIT; if (msg.sender == claimant) return CLAIMANT_BIT; return 0; }As a result, express role-change execution cannot be reached because
roleApprovals[hash]can never becomeBOTH_BITS. Similarly,cancelActioncan never fully cancel a pending action becausecancelApprovals[hash]can never reachBOTH_BITS.Recommendation
Reject deployments where
admin == claimant, or handle the shared-role case explicitly. -
L-01 Low Unused period allowance is lost Unexpected Behavior Acknowledged
Description
The contract does not carry forward unused allowance from one period to the next. When
_rollPerioddetects that the old period has expired, it resetsclaimedThisPeriodto0and starts a new period at the current timestamp. Any unclaimed portion ofmaxPeriodUsdfrom the prior period is discarded.function _rollPeriod() internal { if (block.timestamp >= periodStart + minPeriod) { claimedThisPeriod = 0; // prior-period usage is cleared with no rollover accounting periodStart = block.timestamp; } }This behavior may be intentional, but a claimant could reasonably expect missed allowance to accumulate or at least remain claimable until explicitly forfeited. So if the claimant forgets to call
claimduring a period, the unused portion of that period’s budget is lost.Recommendation
Document this behavior explicitly. If rollover is desired, add separate accounting for unspent allowance rather than resetting only
claimedThisPeriod. -
L-02 Low Claimant cannot bound token output Unexpected Behavior Acknowledged
Description
claimlets the claimant choose a_usdAmount, but it does not let them enforce a minimum acceptable token amount.function claim(address _token, uint256 _usdAmount) external onlyClaimant { ... uint256 tokenAmount = _usdToTokens(_usdAmount, cfg); // amount is fixed only at execution time claimedThisPeriod += _usdAmount; IERC20(_token).safeTransfer(claimant, tokenAmount); emit Claimed(claimant, _token, tokenAmount, _usdAmount); }Example:
- The claimant checks
getTokenAmountand sees that claiming$1,000should return0.5 WETH. - Before the transaction is mined, the oracle price updates upward (or receives a bad oracle update).
claimexecutes using the new higher price, so the claimant receives only0.45 WETH.- The full
$1,000is still counted againstclaimedThisPeriod.
Recommendation
Extend
claimto accept a claimant-specified minimum token amount, for exampleminTokenAmount, and revert when the oracle-priced output falls below that floor. - The claimant checks
-
L-03 Low Uniform action delay across risk levels Configuration Acknowledged
Description
The contract uses a single
ACTION_DELAYfor all delayed operations, including role changes, emergency withdrawals, and routine configuration updates. This keeps the implementation simple, but it also means actions with very different risk profiles all share the same review window.For example,
signalAdminChange,signalWithdraw, andsignalPriceFeedChangeall mature after the same7 days. In practice, a bad role change or withdrawal is much more security-sensitive than a routine oracle update. A single delay forces the system to choose one compromise between security response time and operational flexibility.As a result,
- High-impact actions such as role changes and
withdrawdo not get a longer reaction window than routine parameter updates. - Operational changes such as price feed or cap updates cannot be expedited without also shortening the delay for more dangerous actions.
- The timelock cannot be tuned per action type to match the actual security and operational needs of the escrow.
Recommendation
Consider separating delays by action class. A common split is to use a longer delay for role changes and emergency withdrawals, and a shorter delay for routine configuration changes such as feed or cap updates.
- High-impact actions such as role changes and
-
L-04 Low Fixed 24h price staleness assumption Logical Error Acknowledged
Description
_getPricehardcodes a single24 hoursfreshness window for every configured Chainlink feed. That assumes all supported assets share the same acceptable staleness threshold, but in practice feeds can have different heartbeats.function _getPrice(TokenConfig memory cfg) internal view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = cfg.priceFeed.latestRoundData(); if (answer <= 0) revert InvalidPrice(); if (block.timestamp - updatedAt > 24 hours) revert StalePrice(); // fixed threshold for every feed return uint256(answer); }As a result, feeds whose expected freshness is shorter than
24 hourscan still be accepted even after they should be treated as stale, allowingclaimto settle against outdated prices. Similarly, feeds whose heartbeat is longer than24 hourscan become unusable even when the oracle is behaving as configured, blocking otherwise valid claims.Recommendation
Store a per-token maximum price age alongside each feed configuration and check
updatedAtagainst that token-specific value instead of a universal24 hours. If the escrow is intended for L2 deployment, also consider gating claims on the relevant sequencer uptime feed. -
I-01 Informational Bilateral recovery depends on both roles Warning Acknowledged
Description
The escrow treats
adminandclaimantas equal co-signers for recovery and cancellation. Either role can initiate replacement of the other afterACTION_DELAY, andcancelActionrequires approvals from both roles. As a result, the contract's safety model is bilateral: theclaimantis not only a capped beneficiary, but also a participant in privileged recovery flow.function cancelAction(bytes32 actionHash) external onlyAdminOrClaimant { cancelApprovals[actionHash] |= _callerBit(); if (cancelApprovals[actionHash] == BOTH_BITS) { delete pendingActions[actionHash]; // both roles must participate } }In a key-compromise scenario,
adminandclaimantmust work together to cancel hostile pending actions before execution.ACTION_DELAYprovides notice and time to coordinate, but it does not create a unilateral admin-side veto.For example:
- An attacker uses a leaked
claimantkey to callsignalAdminChange(attackerAdmin). - The honest
admincannot clear that action alone, becausecancelActionrequires both role approvals. - If the honest
claimantstill has practical control of the leaked key, theadminandclaimantcan coordinate to cancel the action beforeACTION_DELAYexpires. - If they fail to coordinate in time,
attackerAdmincan execute the rotation after the delay.
Recommendation
Document the trust assumptions explicitly:
claimantis an equal co-signer in recovery and cancellation flows. Operationally, both roles should be prepared to act together if a key is leaked, because hostile pending actions cannot be cancelled by one side alone. If this bilateral model is not desired, recovery powers should be concentrated on an admin-side multisig instead of being shared with the claimant. - An attacker uses a leaked
No findings match.
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.