Building on Avalanche? Ava Labs can cover up to 75% of your audit, and we take 10% off our quote. Avalanche audits: up to 75% covered. See how
Blog

Potlock: Four Findings in Payouts, Refunds, and Fees

Published

Potlock NEAR Rust Smart Contract Security Assessment

Potlock is a funding platform for non-profits and communities, built on NEAR. We found 20 issues across their contracts. This post examines the four critical and high-severity findings: blocked payouts, corrupted payout state, misdirected refunds, and miscalculated fees.

Read the full security report.

GUV-1: DoS of Process Payouts - Critical Severity

Observation

The admin_process_payouts function halts the entire payout process if a single application is unapproved. One bad entry blocks everyone else’s payout.

The code snippet below shows the problematic logic:

// pot:admin_process_payouts:pot/src/payout.rs
for (project_id, v_app) in self.applications_by_id.iter() {
	// TODO: update this to only go through approved applications
	// mapping
    self.assert_approved_application(&project_id); // TODO: check that the project is not owner, admin or chef
    let application = Application::from(v_app);
    // ...if there are payouts for the project...
    if let Some(payout_ids_for_project) = self.payout_ids_by_project_id.get(&project_id)
	{
        // TODO: handle milestones (for now just paying out all payouts)
        for payout_id in payout_ids_for_project.iter() {
            let payout = Payout::from(self.payouts_by_id.get(&payout_id).expect("no payout"));
            if payout.paid_at.is_none() {
	            // ...transfer funds...
                Promise::new(application.project_id.clone())
                    .transfer(payout.amount.0)
                    .then(
                    Self::ext(env::current_account_id())
	                    .with_static_gas(XCC_GAS)
	                    .transfer_payout_callback(payout));
            }
        }
    }
}
self.all_paid_out = true;

A proof of concept in the report showed how a malicious actor could use this flaw to stop the payout process, effectively causing a denial of service.

Impact

An unapproved application could block the payout batch, preventing approved recipients from receiving their funds.

Proposed Solution

The fix was to change the payout process only to include approved applications.

Takeaway

When processing a batch, test whether one invalid entry can block every valid entry. Make the intended failure behavior explicit.

Remediation

The Potlock team changed the loop to iterate only over approved applications.

Code Snippet

// Iteration now only includes approved applications.
for (project_id, v_app) in self.approved_applications_by_id.iter() {
    ...
}

GUV-2: State Corruption On Setting Payout - Critical Severity

Observation

We identified a second critical flaw in the chef_set_payouts function. This function sets application payouts but fails to completely clear the payout_ids_by_project_id nested collection, leaving an empty UnorderedSet that could cause the collection to become inconsistent with further calls to chef_set_payouts.

The code snippet illustrates this flaw:

// pot:chef_set_payouts:pot/src/payout.rs
for application_id in self.approved_application_ids.iter() {
    if let Some(payout_ids_for_application) = self.payout_ids_by_project_id.get(&application_id) {
        for payout_id in payout_ids_for_application.iter() {
            self.payouts_by_id.remove(&payout_id);
        }

        self.payout_ids_by_project_id.remove(&application_id);
    }
}

Impact

Repeated calls could leave payout collections inconsistent, affecting subsequent payouts.

Proposed Solution

Ensure the main and nested collections were completely cleared within chef_set_payouts, using .clear() to prevent state corruption.

Takeaway

Test repeated updates to nested collections, including deletion and reinsertion. Check that related collections remain consistent.

Remediation

The remediation was implemented as follows:

// Proper cleanup of nested collections.
self.payout_ids_by_project_id.clear();

GUV-3: User Funds Not Returned On Error From Sybil Provider - High Severity

Observation

During the Potlock smart contract audit, a major vulnerability was spotted concerning how user funds were handled if there was an error from the Sybil provider. The callback parameters were set up incorrectly, leading to funds being sent to the contract instead of being refunded to the user.

Code Snippet

// Original flawed callback implementation
#[private]
pub fn sybil_callback(
    &mut self,
    deposit: Balance,
    project_id: Option<ProjectId>,
    message: Option<String>,
    referrer_id: Option<AccountId>,
    matching_pool: bool,
    #[callback_result] call_result: Result<bool, PromiseError>
) -> Promise {
    let caller_id = env::predecessor_account_id();

    if call_result.is_err() {
        // Funds are mistakenly sent to the contract itself
        Promise::new(caller_id).transfer(deposit);
    }
}

Impact

A Sybil-provider error sent the refund to the contract itself, leaving the donor without the expected refund.

Proposed Solution

The solution was to correct the callback parameter handling to ensure that the funds would be accurately returned to the user’s account in case of a Sybil provider error rather than being wrongly sent to the contract.

Takeaway

Pass the original caller explicitly when a callback needs to refund them. Check the account receiving the refund in the error-path test.

Remediation

The fix involved adjusting the callback function to properly return funds to the user in case of a Sybil provider error by passing the correct caller ID.

Code Snippet

// Corrected callback implementation
#[private]
  pub fn sybil_callback(
      &mut self,
      caller_id: AccountId,
      deposit: Balance,
      project_id: Option<ProjectId>,
      message: Option<String>,
      referrer_id: Option<AccountId>,
      matching_pool: bool,
      #[callback_result] call_result: Result<bool, PromiseError>,
  ) -> Promise {
      if call_result.is_err() {
          log!(format!(
              "Error verifying sybil check; returning donation {} to donor {}",
              deposit, caller_id
          ));
          Promise::new(caller_id).transfer(deposit);
          env::panic_str(
              "There was an error querying sybil check. Donation has been returned to donor.",
          );
      }

GUV-4: Arithmetic Issues In Fee Calculation - High Severity

Observation

We’ve noticed several arithmetic issues in the fee calculation.

Division before multiplication

Integer division by 10,000 discards the remainder before the fee rate is applied. Amounts below 10,000 base units produce a zero fee; other amounts can also be undercharged.

Incorrect rounding direction

The reviewed implementation used the same calculation for protocol, referrer, and chef fees. The proposed fix rounds the protocol fee up and the referrer and chef fees down. These directions reflect this protocol’s fee policy; they are not a universal rule for every financial calculation.

Code Snippet

// Flawed fee calculation method
pub(crate) fn calculate_fee(
    amount: u128,
    basis_points: u32
) -> u128 {
    let total_basis_points = 10_000u128;
    let fee = (amount / total_basis_points) * basis_points as u128; // Division before multiplication can lead to precision loss
    fee
}

Impact

The calculation could undercharge fees or charge no fee on small amounts.

Proposed Solution

Multiply before dividing, with appropriate overflow handling, and apply the rounding direction defined for each fee. The example below shows the report’s proposed calculation.

Takeaway

Define the rounding policy for each fee and test small amounts, boundary values, and overflow behavior.

Remediation

Potlock implemented the calculation below, multiplying before division and choosing the rounding direction by fee type.

Code Snippet

fn calculate_fee(amount: u128, basis_points: u32, is_protocol: bool) -> u128 {
    let total_basis_points = 10_000u128;
    let amount = basis_points as u128 * amount;
    if !is_protocol {
        return amount / total_basis_points
    }
    amount.div_ceil(total_basis_points)
}

Conclusion

Test payout batches with invalid entries, repeated state updates, failed callbacks, and small fee amounts. These cases exposed the four findings described here.

Potlock fixed all of them and let us publish.

Discuss a smart contract security review.

Author

Erick Fernandez - Blockchain Engineer, Founder of Apexwins, and Technical Writer

Tell us what you need to secure.

Describe your system, main concern, and deadline. We’ll reply with scoping questions and a proposed next step.

Discuss your scope