Celestia Q2 2025 CIP-31 Audit Report Final
Security Audit Report
Celestia Q2 2025: CIP 31
Last revised 22.04.2025
Authors: Martin Hutle, Tatjana Kirda ©2025 Informal Systems Celestia Q2 2025
Contents Audit overview 3 The Project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Scope of this report . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Audit plan . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Conclusions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Audit Dashboard 4 Target Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Engagement Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Severity Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
System Overview 5 Account types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 Vesting calculations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
Threat Model 7 Account types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 Reward distribution . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 Vesting calculations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 Delegations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
Findings 11 Vesting Restriction Bypass Through Custom Withdrawal Addresses . . . . . . . . . . . . . . . . . . . . . 12 Vesting Schedule Not Updated on Reward Claim After Unbonding . . . . . . . . . . . . . . . . . . . . . . 13 Discrepancy Between CIP-31 and Implementation for Denominations Not in Original Vesting . . . . . . . 14
Appendix: Vulnerability classification 15
Disclaimer 18
2 ©2025 Informal Systems Celestia Q2 2025
Audit overview The Project In April 2025, Celestia engaged Informal Systems to work on a partnership and conduct a security audit of the following items:
- CIP-31
- Implementation of the CIP-31 in Cosmos SDK, under the provided PR
Relevant code commits The audited code was from: • CIP repository – commit hash 39544d04c64568f98d0ba984b01f3fb084139df5 • Cosmos SDK repository – commit hash dba8171d9f829d90134b1669468831625ee89b0e
Scope of this report The audit focused on verifying that the code implementation aligns with the specification outlined in CIP-31. In addition to assessing compliance with the specification, the audit also aimed to identify any potential issues or unintended behaviors introduced by the recent code changes. Notably, while the MaxCommissionRate was included in CIP-31, it was not within the scope of this audit. Additionally, any Cosmos SDK code not covered by the pull request was assumed to be correctly implemented.
Audit plan The audit was conducted between April 9th, 2025, and April 17th, 2025 by the following personnel: • Martin Hutle • Tatjana Kirda
Conclusions After a thorough review of the submitted PR, we found it to be generally well-implemented and in alignment with the CIP-31 specification. During the audit, we identified three findings: two classified as high severity and one as informational. Full details of these issues can be found on the Findings page.
3 ©2025 Informal Systems Celestia Q2 2025
Audit Dashboard Target Summary • Type: Protocol and Implementation • Platform: Go • Artifacts: – CIP-31 – CIP-31 implementation in Cosmos SDK
Engagement Summary • Dates: 09.04.2025 - 17.04.2025. • Method: Manual code review
Severity Summary
Finding Severity Number Critical 0 High 2 Medium 0 Low 0 Informational 1 Total 3
4 ©2025 Informal Systems Celestia Q2 2025
System Overview The CIP-31 outlines the incorporation of staking rewards directly into lockup accounts. When a vesting account earns rewards from staking, the system adds the reward amount to the lockup balance and adjusts the daily unlock rate based on the remaining lockup duration (ref).
Account types A vesting account can be one of the following: • Continuous vesting account • Periodic vesting account • Delayed vesting account • Permanent vesting account. CIP-31 is implemented for continuous and delayed vesting accounts only. The creation of periodic and permanent vesting accounts is disabled. Previously created accounts continue to exist, and if a reward is distributed to such an account, it is not locked.
Vesting calculations BaseVestingAccount = (OV[], DF[], DV[], E), where • OV[i] is the original vested amount for denomination index i; if OV[i] = 0, the denomination is not vested. • DF[i] is the delegated free (i.e., unlocked) amount. • DV[i] is the delegated vesting (i.e., locked) amount. • E is the end time of the vesting, after which all coins are unlocked. ContinuousVestingAccount = (BaseVestingAccount, S), where • S is the (Unix) time the continuous unlocking starts. DelayedVestingAccount = BaseVestingAccount. Further, we denote with BC[i] the account balance in the banking module. V is the number of vesting (locked) coins. V’ is the number of vested (unlocked) coins.
Continuous vesting account: V’[i] = • OV[i] for t <= S • (t-S)/(E-S) * OV[i] for S < t < E • 0 for t >= E V[i] = OV[i] - V’[i]
Delayed vesting account: V’[i] = • 0 for t < E • OV[i] for t >= E V[i] = OV[i] - V’[i]
5 ©2025 Informal Systems Celestia Q2 2025
Modifying OV[i] If a reward R[i] (for denomination i) is distributed to the continuous or delayed vesting account, then OV[i] is set to • OV[i] + R[i] for OV[i] > 0 • 0 for OV[i] = 0 In addition, the rewards are transferred to the account balance BC[i].
6 ©2025 Informal Systems Celestia Q2 2025
Threat Model Account types Invariant 1: No periodic or permanent vesting account can be created. Conclusion: The invariant holds. The provided PR removes the logic responsible for creating periodic and vesting accounts. As a result, any attempt to create these account types now returns an error (code ref). Currently, only continuous vesting accounts and delayed vesting accounts can be created via messages. This restriction is also enforced within the CreateVestingAccount method (code ref). Invariant 2: This list of vesting accounts defined in the system overview is exhaustive. Conclusion: The invariant holds. The file x/auth/vesting/types/vesting_account.go defines types of BaseVestingAccount (code ref), ContinuousVestingAccount (code ref), PeriodicVestingAccount (code ref), DelayedVestingAccount (code ref), and PermanentLockedAccount (code ref). The BaseVestingAccount implements core functionality that is shared by all vesting account types. Invariant 3: If a reward is distributed to a periodic or permanent vesting account, the vested amount and the vesting schedule remain unchanged. Conclusion: The invariant is maintained through the nil implementation of UpdateSchedule for both periodic (code ref) and permanent (code ref) vesting accounts. When rewards are distributed, the UpdateSchedule method is invoked but returns nil for these account types, allowing the standard reward distribution flow to proceed without changing the vesting schedule (code ref).
Reward distribution Invariant 4: Every time a reward is distributed to a vesting account, the locking schedule is adapted accordingly. Conclusion: The invariant does not hold. A user can withdraw rewards and bypass the vesting schedule update by unbonding tokens or setting a non-vesting account as the WithdrawAddr. See the threats below for more details. Threat: Not all sources/mechanisms of rewards are covered. Conclusion: This is a legitimate threat. See finding Vesting Schedule Not Updated on Reward Claim After Unbonding. Threat: Vesting account schedule update can be omitted if the WithdrawAddrEnabled parameter is set to true. Conclusion: This is a legitimate threat. See finding Vesting Restriction Bypass Through Custom Withdrawal Addresses. Invariant 5: No other changes of the account balance lead to changes in the vesting schedule. Conclusion: The vesting schedule can be affected by three main parameters: OriginalVesting, StartTime, EndTime, and VestingPeriods. However, none of the current vesting account types allow direct modification of StartTime or EndTime, and they cannot be changed after account creation. Additionally, for periodic vesting accounts, there are no other methods that modify VestingPeriods after creation. The only mechanism for updating OriginalVesting is through the UpdateSchedule function, which is implemented for continuous and delayed vesting accounts. This
7 ©2025 Informal Systems Celestia Q2 2025
method is only called within the withdrawDelegationRewards function, which further updates the account balance (code ref). Invariant 6: The vesting schedule that has already been completed cannot be updated. Conclusion: The invariant holds. The vesting schedule update is handled by the UpdateSchedule function. Implementations for both ContinuousVestingAccount (code ref) and DelayedVestingAccount (code ref) explicitly check if the current block time is greater than or equal to the end time. If the schedule is completed, they return nil without making any changes. The UpdateSchedule function always returns nil for other vesting account types.
Vesting calculations Continuous vesting account Invariant 7: The distribution of locked (vesting) and unlocked (vested) coins is implemented as defined in the specification and the system overview. Conclusion: The invariant holds. The calculation of vested coins is handled by the GetVestedCoins function (code ref). The x calculates how much time has elapsed since vesting started, y represents the total duration of vesting, and s computes the fraction of vesting time that has passed. For each coin, the original amount is converted to a decimal for precision, multiplied by the vesting scalar s, rounded to the nearest integer, and then used to create a new coin that is added to the result. The calculation of the vesting coins is based on the GetVestedCoins, and it is handled in the GetVestingCoins function (code ref). The amount of vesting coins is calculated by subtracting the result of GetVestedCoins from the OriginalVesting. The specification captures the implementation with close accuracy. The audited PR does not modify these functions, and the current calculation implementation is assumed to be correct. Threat: Corner cases t <= S or t >= E are not considered. Conclusion: The threat does not hold. The function GetVestedCoins handles the calculation of the vested (unlocked) coins (code ref). In case blockTime.Unix() <= cva.StartTime, the function will return an empty vestedCoins, which is initialized as var vestedCoins sdk.Coins (code ref). If the blockTime.Unix() >= cva.EndTime original vesting amount will be returned (code ref). The function GetVestingCoins calculates the number of locked coins by subtracting the vested coins from the original vesting (code ref). Threat: E > S is not ensured. Conclusion: The threat does not hold. The function GetVestedCoins doesn’t explicitly check if the cva.EndTime
- cva.StartTime is not equal to zero, which can cause panic when the function Quo is called (code ref). However, the function ensures beforehand that the blockTime is strictly greater than the start time and strictly less than the end time (code ref). This implies that the cva.StartTime < blockTime < cva.EndTime. Threat: Incorrect handling of OV[i] = 0 or nil. Conclusion: The threat does not hold. The function GetVestedCoins initializes an empty vestedCoins variable (code ref). The GetVestingCoins function uses Coins.Sub (code ref) that handles empty coins correctly (code ref) and returns Coins{} if zero or nil.
Delayed vesting account Invariant 8: The distribution of locked (vesting) and unlocked (vested) coins is implemented as defined in the specification and the system overview. Conclusion:
8 ©2025 Informal Systems Celestia Q2 2025
The invariant holds. The calculation of vested coins is handled by the GetVestedCoins function (code ref). If the vesting has passed, the function returns the OriginalVesting amount. Otherwise, nil is returned. The calculation of the vesting coins is based on the GetVestedCoins function, and it is handled in the GetVestingCoins function (code ref). The amount of vesting coins is calculated by subtracting the result of GetVestedCoins from the OriginalVesting. The specification captures the implementation with close accuracy. The audited PR does not modify these functions, and the current calculation implementation is assumed to be correct. Threat: Incorrect handling of OV[i] = 0 or nil. Conclusion: The threat does not hold. The function GetVestedCoins returns nil if the schedule hasn’t elapsed (code ref). The GetVestingCoins function uses Coins.Sub (code ref) that handles empty coins correctly (code ref) and returns Coins{} if zero or nil.
Modifying OV[i] Invariant 9: Reward distribution is implemented as described in the system overview and adheres to the provided specification. Conclusion: The invariant does not hold. The finding Discrepancy Between CIP-31 and Implementation for Denominations Not in Original Vesting emerged during the code review. Invariant 10: At any time, the currently locked value must be smaller or equal to the liquid funds plus the locked delegations: V[i] <= DV[i] + BC[i] We first check that the invariant is ensured by the mechanism of increasing OV[i] by R[i]: If R[i] is added to OV[i] and BC[i], we have for the continuous vesting account: (t-S)/(E-S) * (OV[i] + R[i]) <= (t-S)/(E-S)*OV[i] + R[i] <= DV[i] + BC[i] + R[i] which shows this property holds in theory. We now also need to check the code for correct implementation. Conclusion: The invariant holds. When the withdrawDelegationRewards function is called, it first updates the vesting schedule through UpdateSchedule (code ref) and then transfers coins using k.bankKeeper.SendCoinsFromModuleToAccount (code ref). The bank keeper’s SendCoins function (code ref) internally calls subUnlockedCoins (code ref), which uses the LockedCoins function (code ref) to determine how many coins are locked. The LockedCoins calculation computes the locked coins by subtracting the minimum of vesting coins and DelegatedVesting from the original vesting amount (e.g., code ref) based on the current block time and vesting schedule for the specific vesting account type, and then subtracts delegated vesting from this amount (code ref). The check spendable, hasNeg := sdk.Coins{balance}.SafeSub(locked) from the subUnlockedCoins function (code ref) ensures that the account’s balance is greater than or equal to the locked coins and that the remaining spendable amount (balance - locked) is sufficient for the transfer. Since the locked amount is derived from the vesting schedule and delegated vesting, and balance represents BC[i], this check effectively enforces that BC[i] >= V[i] - min(V[i], DV[I]). The expression BC[i] >= V[i] - min(V[i], DV[I]) can be rewritten as BC[i] >= V[i] - DV[I] when V[i] > DV[I], otherwise it’s trivially satisfied as BC[i]
= 0, which directly rearranges to V[i] <= DV[i] + BC[I]. Therefore, the implementation is equivalent to the invariant V[i] <= DV[i] + BC[I]. Threat: Rewards can be withdrawn before the vesting schedule is properly updated in situations where the schedule adjustment should occur. Conclusion:
9 ©2025 Informal Systems Celestia Q2 2025
The original vesting amount OV[I] is only updated if the denomination was part of the original vesting schedule, the account is still within its vesting period, and the vesting account is the correct type. If these conditions are met, when the rewards are withdrawn in the withdrawDelegationRewards func- tion, both bank coins and outstanding rewards are updated atomically (code ref). Additionally, the BeforeDelegationSharesModified hook doesn’t move the claimed rewards to the user’s account since the variable withdrawNow is set to false (code ref), nor is the vesting schedule updated (code ref). However, when rewards are withdrawn after the user no longer has a delegation to the validator, the issue described in the Vesting Schedule Not Updated on Reward Claim After Unbonding finding occurs. Threat: OV[I] can be influenced by factors other than delegation rewards. Conclusion: The threat does not hold. The OV[I] amount can only be modified via the UpdateSchedule method, which is called exclusively from the withdrawDelegationRewards function (code ref). For both PeriodicVestingAccount and PermanentLockedAccount, the OV[i] value cannot be increased at all, as the UpdateSchedule function returns nil. Threat: OV[I] is decreased due to negative R[i]. Conclusion: The threat does not hold. The UpdateSchedule function uses the finalRewards variable as the amount being added to the OV[I] (code ref). When finalRewards is created, it is guaranteed to be a positive amount, as the TruncateDecimal function is used during its creation (code ref). The TruncateDecimal function employs the NewCoin constructor (code ref), which ensures that the coin amount is both valid (code ref) and non-negative (code ref). Prior to calling UpdateSchedule, the outstanding.Rewards are added to the final rewards using the Add function (code ref). The implementation of the Add function notes that Add will never return Coins where one Coin has a non-positive amount (code ref). For the purposes of this audit, this statement is assumed to be accurate.
Delegations Note: x/vesting does not implement the delegation but only tracks them with respect to vesting. As mentioned in the documentation of the standard Cosmos SDK, if a validator gets slashed, it might be the case that DV > 0 even after all funds have been unlocked (V = 0). The modification to increase OV if rewards are distributed does not alter this scenario compared to the standard Cosmos SDK case.
10 ©2025 Informal Systems Celestia Q2 2025
Findings Finding Type Severity Status
Vesting Restriction Bypass Through Implementation High Resolved Custom Withdrawal Addresses
Vesting Schedule Not Updated on Implementation High Resolved Reward Claim After Unbonding
Discrepancy Between CIP-31 and Documentation Informational Resolved Implementation for Denominations Not in Original Vesting
11 ©2025 Informal Systems Celestia Q2 2025
Vesting Restriction Bypass Through Custom Withdrawal Addresses
ID IF-FINDING-001 Severity High Impact 2 - Medium Exploitability 3 - High Type Implementation Status Resolved
Involved artifacts • x/distribution/keeper/delegation.go • x/distribution/keeper/msg_server.go
Description When the withdrawDelegationRewards function is invoked, the vesting schedule is only updated if the withdrawAddr is of type types.VestingAccount (code ref). This address is derived from the delAddr (code ref). However, the withdrawAddr can be modified using the SetWithdrawAddress method (code ref) if the WithdrawAddrEnabled parameter is set to true, potentially bypassing the vesting logic.
Problem Scenarios Users with vesting accounts can set a regular account they control as their withdrawal address through the SetWithdrawAddr function. When rewards generated from delegated vesting tokens are claimed, they are sent directly to this regular account without any vesting restrictions.
Recommendation After sharing the finding with the client, the issue was resolved by modifying the account type check to verify whether the delegator account is a vesting account. If it is, the vesting schedule is updated and the rewards are sent to the vesting account. If it is not, the rewards are sent to the WithdrawAddr. Additionally, the PR
Excerpt (19994 of 32289 characters). Read the whole page on informalsystems/audits ↗