Introduction
On December 2, 2024, Demox Labs tasked zkSecurity with auditing its Token Disbursement program. The specific code to review was shared via GitHub as a public repository (https://github.com/demox-labs/aleo-standard-programs at commit a5642c6f7f6150fc0d29dc30732894d31a9a3eeb
). The audit lasted 3 workdays with 1 consultant.
The program was found to be clear, accompanied with thorough tests. A few findings have been reported to the demox-labs team, which are detailed in the following sections.
Note that security audits are a valuable tool for identifying and mitigating security risks, but they are not a guarantee of perfect security. Security is a continuous process, and organizations should always be working to improve their security posture.
Scope
The scope of the audit is the token_disbursement.aleo
program.
Overview of Token Disbursement Program
The token_disbursement.aleo
program is a vault to hold pAleo token and disburse a specific amount of tokens to the recipients after the one-year lock. If a disbursement is not claimed for too long (about one year after the unlock), the disbursement can be canceled, and the token will be transferred to a cold address. As pAleo is a liquid staking token of Aleo, the intrinsic value will continuously increase with the staking rewards. The recipients are able to withdraw the staking rewards before token unlock.
Below is the typical lifecycle of a disbursement (called Claim
in the program):
- Create: The
Claim
is created by specifying claim_id
, the amount of pAleo to lock and recipient address. In the same transaction, the caller will transfer the required pAleo token to the program.
- Withdraw Rewards: The recipient can withdraw the staking reward before unlock time. The receipt calls the
withdraw_rewards
function specifying claim_id
and the amount of pAleo to withdraw. The program will check that after this withdrawal, the value of remaining pAleo is no less than the initially locked value. The recipient can withdraw many times, but only before the unlock time.
- Withdraw Principal: After the unlock time (about 1 year since mainnet genesis), the recipient can withdraw all the locked pAleo token in the
Claim
.
- Cancel: If a
Claim
is not withdrawn within 1 year after the unlock time, it can be canceled and the pAleo token will be transferred to a fixed cold address.
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.
Description. If a Claim
is not withdrawn after the cancel time, it can be canceled and the pAleo token will be transferred to a fixed cold address. The cancel time in the program is marked by block height. However, on Aleo the block time is not constant, which can be impacted by the network conditions or future upgrades. This means that the cancel time may fluctuate greatly.
// The unlock timestamp for all of the distributions, 9/4/2025
const UNLOCK_TIMESTAMP: u64 = 1_757_015_686u64;
// Minimum cancel block height, approximately 2 years after genesis
const MIN_CANCEL_HEIGHT: u32 = 22_500_000u32;
Furthermore, the unlock time is marked by timestamp. In extreme cases, if the block time decreases a lot, the cancel time might become smaller than the unlock time. This will lead to a Claim
being canceled before withdrawal.
Impact. The cancel time is not fixed timestamp. In extreme cases the Claim
can be canceled before withdrawal.
Recommendation. We recommend to use a fixed timestamp as the threshold for cancel time as program has access to the current timestamp via the time oracle.
Description. A claim_id
is an u64
integer specifying the ID of a Claim
. This ID is intended to be unique and should not have any duplicates. The program stores all the Claim
in a map with ID as the key. When creating a new Claim
, it will check if there is the same claim_id
in the map. However, when a Claim
is canceled, the program will entirely remove it from the map. Afterward, others can create a new Claim
using the same claim_id
.
async function finalize_cancel(
public f0: Future,
public claim_id: u64,
public paleo_amount: u128
) {
// Await the transfer completing
f0.await();
// Assert that the current timestamp is after the minimum cancel height
assert(block.height > MIN_CANCEL_HEIGHT);
// Get the claim
let claim: Claim = claims.get(claim_id);
// Assert that the transfer amount is the remaining pAleo
assert_eq(claim.paleo_amount, paleo_amount);
// Remove the claim
claims.remove(claim_id);
}
Impact. As claim_id
is supposed to be a unique representation of a Claim
. If there are duplicates, it can cause significant confusion for off-chain actors.
Recommendation. To prevent duplicated claim_id
values, it is recommended to mark the Claim as empty within the cancel
function instead of deleting it. Alternatively, we can prohibit the creation of new Claim
instances after the unlock time.