Date of engagement: 4th January 2024 - 19th January 2024
-
Timur GüvenkayaPrincipal advisor
Engagement Dashboard
Audit results
Guvenkaya conducted a security assessment of the Potlock core smart contracts from 4th January 2024 to 19th January 2024. During this engagement, a total of 20 findings were reported. 2 of the findings were critical, 2 high, 1 medium, and the remaining were either low or informational severity. All major issues were fixed by the Potlock team.
Engagement record
- Client
- Potlock
- System
- Potlock core smart contracts on NEAR
- Review period
- 4th January 2024 - 19th January 2024
- Reviewed baseline
71f35051f81c333c85fafc42d073173191ad14ca- Paths in scope
- 47
- Principal advisor
- Timur Güvenkaya
Timeline
- Review start4th January 2024
- Draft report20th January 2024
- Final report30th January 2024
Findings by severity
Finding status at final report
Remediation complexity
Impact
Likelihood
Advisory and Client
The advisory
Guvenkaya specializes in security for digital assets and financial infrastructure, from smart contracts and protocols to applications, infrastructure, custody and cryptography.
Our team, including the sitting Heads of Security at Gauntlet and Agora, brings experience from Kraken, OpenZeppelin, Binance, ZKsync, Nillion, Invicti and EY. Across the team: 200+ security reviews, $30B+ in volume secured, 13 published cryptography research papers, and credentials including OSCP, OSWE and CISM.
- Every reviewer has a public bio and is named before you sign. We match expertise to your system, with a principal leading scoping and checking findings.
The client
PotLock is the portal for public goods, non-profits, and communities to raise funds transparently through our global donor network
Scope and Review Boundaries
Review depths
- Deep
- Line-by-line review with explicit attack hypotheses, adversarial reasoning about state transitions, and reproduction attempts for each hypothesis that survived reading.
- Supporting
- The code was read for its effect on a reviewed flow. Behaviour outside that effect was not assessed.
- Targeted
- Entry points were exercised or triaged against the flow's security properties rather than read in full.
This is the code we read, and how closely. 47 paths are in scope. The original report counts 4,273 lines of code reviewed. Each path links to the file at the reviewed commit.
Out of scope
The audit will include, but is not limited to, reviewing the code for security vulnerabilities, coding practices, and architecture. The audit does not include a review of the dependencies.
Assessment Framework
Guvenkaya rates findings by likelihood and impact so remediation can be prioritized by technical risk, business context, and the decision the engagement is meant to support.
Rating chain applied to every finding
- 01 Likelihood Likely, possible or rare
- 02 Impact Severe, moderate or negligible
- 03 Severity Derived from the matrix below
- 04 Remediation complexity Simple, moderate or difficult
- 05 Status Not fixed, fixed, acknowledged or scheduled
Impact
| Impact | What it means here |
|---|---|
| Severe | Exploitation could result in critical loss or compromise, such as full system control, substantial financial loss, or severe reputational damage. |
| Moderate | Exploitation may lead to limited data loss, partial compromise, moderate financial impact, or noticeable degradation of services. |
| Negligible | Exploitation has minimal impact, such as minor data exposure without significant consequences or slight inconvenience without substantial disruption. |
Likelihood
| Likelihood | What it means here |
|---|---|
| Likely | The vulnerability is easily discoverable and not overly complex to exploit. |
| Possible | The vulnerability presents some challenges either in discovery or in the complexity of the attack. |
| Rare | The vulnerability is either very difficult to discover or complex to exploit, or both. |
This matrix provides a nuanced view, taking into account both the ease of discovering a vulnerability and the complexity involved in exploiting it.
| Attack Complexity \ Discovery Ease | Obvious | Concealed | Hidden |
|---|---|---|---|
| Complex | Possible | Rare | Rare |
| Moderate | Likely | Possible | Rare |
| Straightforward | Likely | Possible | Possible |
Severity
| Severity | What it means here |
|---|---|
| Critical | Assigned to vulnerabilities with severe impact and a likely likelihood of exploitation. |
| High | For vulnerabilities with either severe impact but only a possible likelihood, or moderate impact with a likely likelihood. |
| Medium | Used for vulnerabilities with severe impact but a rare likelihood, moderate impact with a possible likelihood, or negligible impact with a likely likelihood. |
| Low | For vulnerabilities with moderate impact and rare likelihood, or negligible impact with a possible likelihood. |
| Informational | The lowest severity rating, typically for vulnerabilities with negligible impact and a rare likelihood of exploitation. |
| Likelihood \ Impact | Severe | Moderate | Negligible |
|---|---|---|---|
| Likely | Critical | High | Medium |
| Possible | High | Medium | Low |
| Rare | Medium | Low | Informational |
Remediation complexity
This measures how difficult it is to remediate a finding once it has been identified.
| Remediation complexity | What it means here |
|---|---|
| Simple | Patches or fixes are readily available and easily implemented. |
| Moderate | Requires some time and resources to remediate, but well within the capabilities of most organizations. |
| Difficult | Remediation requires significant resources, specialized skills, or substantial changes to systems or architecture. |
Finding status
This tracks the client's response to each finding and whether remediation has been completed, accepted, or scheduled.
| Status | What it means here |
|---|---|
| Not Fixed | Indicates that the vulnerability has been identified but no remedial action has been taken yet. This status is crucial for newly discovered vulnerabilities or those awaiting prioritization. |
| Fixed | This status is applied when the vulnerability has been successfully remediated. It implies that appropriate measures, such as patching, configuration changes, or architectural modifications, have been implemented to resolve the issue. |
| Acknowledged | This status is used for vulnerabilities that have been recognized, but for various reasons (such as risk acceptance, cost, or other business decisions), have not been fixed. It indicates that the risk posed by the vulnerability is known and has been consciously accepted. |
| Scheduled | This status indicates that the vulnerability has been acknowledged and a plan is in place to fix it in the future. It signifies that while remediation hasn't yet occurred, the issue has been prioritized and is part of the planned development roadmap. |
Findings Summary
Findings register
Detailed Findings
GUV-1: DoS of Process Payouts
Description
The admin's payout process function iterates over the application storage, verifying whether each application is approved. However, if a single application is not approved, the system does not process payouts for any applications. A malicious actor can exploit this by adding a new application before the payout process begins, which prevents the function from processing all payouts. Consequently, the admin is forced to either approve a potentially harmful application or halt the system's payout process.
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; } Here is the poc which demonstrates the exploit.
#[tokio::test]async fn dos_on_admin_process_payments() -> color_eyre::Result<()> { // disabled self.assert_cooldown_period_complete(); & // self.assert_round_closed(); To make it easier to test let EnvData { pot_contract, admin, malicious_actor, malicious_actor2, chef, .. } = prepare().await?; malicious_actor .call(pot_contract.id(), "apply") .args_json(json!({})) .max_gas() .deposit(NearToken::from_near(1)) .transact() .await? .into_result()?; malicious_actor2 .call(pot_contract.id(), "apply") .args_json(json!({})) .max_gas() .deposit(NearToken::from_near(1)) .transact() .await? .into_result()?; // Only approve one chef.call(pot_contract.id(), "chef_mark_application_approved") .args_json( json!({"project_id": malicious_actor.id(), "notes": String::new()}), ) .max_gas() .transact() .await? .into_result()?; // process payouts let outcome = admin .call(pot_contract.id(), "admin_process_payouts") .args_json(json!({})) .max_gas() .transact() .await? .into_result(); assert!(outcome.is_err()); assert!(outcome .err() .unwrap() .to_string() .contains("Approved application does not exist")); Ok(()) } Recommendation
We propose iterating only only over approved applications in the payout
Remediation
The Potlock team has fixed the issue by iterating only over approved applications in the payout process in this Pull Request: https:/
GUV-2: State Corruption On Setting Payout
Description
We noticed that the nested collection payout_ids_by_project_id isn't properly cleaned. Only the root collection has been removed. If an empty UnorderedSet is placed in the same entry of the root, the collection will be in an inconsistent state. This occurs when chef_set_payouts is called a second time.
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); test_payout_id = payout_id; } self.payout_ids_by_project_id.remove(&application_id); } } Here is the poc which demonstrates the exploit. We modified the actual contract to make the testing easier
#[tokio::test]async fn chef_set_payout_corrupted_state() -> color_eyre::Result<()> { let EnvData { pot_contract, malicious_actor, chef, .. } = prepare().await?; malicious_actor .call(pot_contract.id(), "apply") .args_json(json!({})) .max_gas() .deposit(NearToken::from_near(1)) .transact() .await? .into_result()?; chef.call(pot_contract.id(), "chef_mark_application_approved") .args_json( json!({"project_id": malicious_actor.id(), "notes": String::new()}), ) .max_gas() .transact() .await? .into_result()?; let outcome = chef .call(pot_contract.id(), "chef_set_payouts") .args_json(json!({"payouts": vec![PayoutInput{ project_id: malicious_actor.id().as_str().parse()?, amount: NearToken::from_near(1).as_yoctonear().into(), }]})) .max_gas() .transact() .await? .into_result()?; println!("OUTCOME LOGS: {:#?}", outcome.logs()); let outcome = chef .call(pot_contract.id(), "chef_set_payouts") .args_json(json!({"payouts": vec![PayoutInput{ project_id: malicious_actor.id().as_str().parse()?, amount: NearToken::from_near(1).as_yoctonear().into(), }]})) .max_gas() .transact() .await? .into_result()?; println!("OUTCOME LOGS: {:#?}", outcome.logs()); Ok(()) } pub fn chef_set_payouts(&mut self, payouts: Vec<PayoutInput>) { self.assert_chef_or_greater(); // verify that the round has closed // self.assert_round_closed(); // verify that payouts have not already been processed assert!( self.all_paid_out == false, "Payouts have already been processed" ); // clear any existing payouts (in case this is a reset, e.g. fixing an // error) // let mut test_payout_id = String::new(); 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); test_payout_id = payout_id; } self.payout_ids_by_project_id.remove(&application_id); } } // get down to business let mut running_total: u128 = 0; // for each payout: for payout in payouts.iter() { // verify that the project exists and is approved self.assert_approved_application(&payout.project_id); // TODO: check that the project is not owner, admin or chef // add amount to running total running_total += payout.amount.0; // set cooldown_end to now + 1 week (?) self.cooldown_end_ms .set(&(env::block_timestamp_ms() + ONE_WEEK_MS)); // TODO: remove //hardcoding to one week, allow owner/admin to //configure add payout to payouts let mut payout_ids_for_application = self .payout_ids_by_project_id .get(&payout.project_id) .unwrap_or(UnorderedSet::new( StorageKey::PayoutIdsByProjectIdInner { project_id: payout.project_id.clone(), }, )); let test_payout = payout_ids_for_application.is_empty(); log!("TEST PAYOUT IS_EMPTY: {}", test_payout); let test_payout = payout_ids_for_application.contains(&test_payout_id); log!("TEST PAYOUT CONTAINS: {}", test_payout); let payout_id = format!( "{}{}{}", payout.project_id, PAYOUT_ID_DELIMITER, payout_ids_for_application.len() + 1 ); let payout = Payout { id: payout_id.clone(), amount: U128::from(payout.amount.0), project_id: payout.project_id.clone(), paid_at: None, }; payout_ids_for_application.insert(&payout_id); self.payout_ids_by_project_id .insert(&payout.project_id, &payout_ids_for_application); self.payouts_by_id .insert(&payout_id, &VersionedPayout::Current(payout)); } Upon calling chef_set_payouts for the second time, the logs indicate an inconsistency. The test_payout appears to be empty, yet simultaneously contains the test_payout_id. This is not correct.
Recommendation
We propose using .clear() to clear the nested collection.
Remediation
The Potlock team has fixed the issue by using .clear() to remove the nested collection in this Pull Request: https:/
GUV-3: User Funds Not Returned On Error From Sybil Provider
Description
We observed that the caller ID in the callback is incorrectly set toenv::predecessor_account_id(). The callback caller is always env::current_account_id(). In this callback, if the Sybil verification fails, the users' funds are returned. However, since the caller is incorrectly set, the funds are sent to the contract itself instead of the user.
#[private] // Public - but only callable by env::current_account_id() 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() { 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.", ); } ... Recommendation
Consider passing caller_id as an argument to the callback.
Remediation
The Potlock team has fixed the issue by using .clear() to remove the nested collection in this Pull Request: https:/
GUV-4: Arithmetic Issues In Fee Calculation
Description
We've noticed several arithmetic issues in the fee calculation.
Division before multiplicaiton
Performing division before multiplication can lead to precision errors or even incorrect results. If the amount is less than total_basis_points, the fee will always be 0. This could potentially allow a full fee bypass when the amount sent is less than total_basis_points. If the amount equals total_basis_points, the fee will always be basis_points. This could result in substantially reduced fees for users when the amount sent equals total_basis_points. If the amount is more than total_basis_points, the fee is not calculated correctly.
Incorrect rounding direction
The protocol should always round AGAINST the user. Therefore, division should round down, allowing the user to pay a lower fee. Since the same function calculates the protocol, referrer, and chef fees, the referrer and chef fees should also round down. However, the protocol fee should round up.
pub(crate) fn calculate_fee( &self, amount: u128, basis_points: u32, ) -> u128 { let total_basis_points = 10_000u128; let amount_per_basis_point = amount / total_basis_points; basis_points as u128 * amount_per_basis_point }} pub(crate) fn calculate_protocol_fee(&self, amount: u128) -> u128 { let total_basis_points = 10_000u128; let amount_per_basis_point = amount / total_basis_points; self.protocol_fee_basis_points as u128 * amount_per_basis_point } pub(crate) fn calculate_referrer_fee(&self, amount: u128) -> u128 { let total_basis_points = 10_000u128; let amount_per_basis_point = amount / total_basis_points; self.referral_fee_basis_points as u128 * amount_per_basis_point } Here is the poc which shows how the order of operations and rounding direction impact calculation
fn calculate_fee( amount: u128, basis_points: u32, ) -> u128 { let total_basis_points = 10_000u128; let amount_per_basis_point = amount / total_basis_points; basis_points as u128 * amount_per_basis_point } fn calculate_fee_better( amount: u128, basis_points: u32, ) -> u128 { let total_basis_points = 10_000u128; basis_points as u128 * amount / total_basis_points } fn calculate_fee_best( 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) } } fn main () { let incorrect = calculate_fee(13133313113777313, 135); let better = calculate_fee_better(13133313113777313, 135); let best_referral = calculate_fee_best(13133313113777313, 135, false); let best_chef = calculate_fee_best(13133313113777313, 135, false); let best_protocol = calculate_fee_best(13133313113777313, 135, true); println!("{incorrect}"); println!("{better}"); println!("{best_referral}"); println!("{best_chef}"); println!("{best_protocol}"); } Recommendation
Consider implementing a method similar to 'calculate_fee_best'. In this method, we round down the referrer fee and chef fee, but round up the protocol fee. Additionally, we perform multiplication before division.
Remediation
The Potlock team has fixed the issue by implementing calculate_fee_best in this Pull Request: https:/
GUV-5: No Runtime Overflow Checks Applied
Description
We've noticed that the profile release isn't applied to contracts during the compilation process. This occurs because the profile release must be located inside the root Cargo.toml to be inherited within the workspace. Given that the release profile includes overflow-checks=true, the generated binary lacks any runtime overflow checks. This could potentially result in overflows, such as in the verify_callback function in sybil/src/stamp.rs, where the stamp count per provider is incremented.
[workspace]members = [ "donation", "pot", "pot_factory", "registry", "sybil", "sybil_provider_simulator"] let initial_storage_usage = env::storage_usage(); self.insert_stamp_record( stamp_id.clone(), stamp.clone(), provider_id.clone(), user_id.clone(), ); provider.stamp_count += 1; self.providers_by_id.insert( &provider_id, &VersionedProvider::Current(provider.clone()), ) Here is the poc which showcase the issue. We modified the smart contract to make it easier to test.
#[tokio::test]async fn overflow_checks_not_applied() -> color_eyre::Result<()> { let EnvData { pot_contract, malicious_actor, .. } = prepare().await?; // pub fn test_underflow(num: u8) -> u8 { // num - 1 // } let val = malicious_actor .call(pot_contract.id(), "test_underflow") .args_json(json!({"num": 0})) .max_gas() .transact() .await? .into_result()? .json::<u8>()?; assert_eq!(val, 255); Ok(())} Recommendation
Transfer the release profile from the workspace Cargo.toml files to the root. Additionally, consider relocating the dependencies as well. There are numerous duplicate dependencies across contracts. Centralizing them in a single root Cargo.toml will simplify dependency management.
Remediation
The Potlock team has fixed the issue by enabling overflow checks in the root Cargo.toml in this Pull Request: https:/
GUV-6: Race Condition in Admin Process Payment
Description
We noticed a race condition in the admin_process_payouts function. This issue arises because payout.paid_at = Some(env::block_timestamp_ms()); is set within a callback. Callbacks execute after several blocks, allowing the admin to potentially run this function multiple times and process more payments than permitted. In this particular scenario, it isn't exploitable because self.all_paid_out=true is set immediately after the loop. Nevertheless, it's advisable to mark the project as paid outside of the callback.
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() { let payout_amount = payout.amount.0; // ...transfer funds... Promise::new(application.project_id.clone()) .transfer(payout_amount) .then( Self::ext(env::current_account_id()) .with_static_gas(XCC_GAS) .transfer_payout_callback(payout), ); } } } } self.all_paid_out = true; #[private] // Public - but only callable by env::current_account_id()pub fn transfer_payout_callback( &mut self, mut payout: Payout, #[callback_result] call_result: Result<(), PromiseError>,) { if call_result.is_err() { log!(format!( "Error paying out amount {:#?} to project {}", payout.amount, payout.project_id )); payout.paid_at = None; self.payouts_by_id .insert(&payout.id.clone(), &VersionedPayout::Current(payout)); } else { log!(format!( "Successfully paid out amount {:#?} to project {}", payout.amount, payout.project_id )); // update payout to indicate that funds have been transferred payout.paid_at = Some(env::block_timestamp_ms()); let payout_id = payout.id.clone(); self.payouts_by_id .insert(&payout_id, &VersionedPayout::Current(payout)); }} Here is the poc which shows an exploitation of the issue if self.all_paid_out was inside of the callback.
// moved self.all_paid_out = true; inside of callback#[tokio::test]async fn admin_process_payouts_race_condition() -> color_eyre::Result<()> { let EnvData { pot_contract, malicious_actor, chef, admin, .. } = prepare().await?; malicious_actor .call(pot_contract.id(), "apply") .args_json(json!({})) .max_gas() .deposit(NearToken::from_near(1)) .transact() .await? .into_result()?; chef.call(pot_contract.id(), "chef_mark_application_approved") .args_json( json!({"project_id": malicious_actor.id(), "notes": String::new()}), ) .max_gas() .transact() .await? .into_result()?; let malicous_account_before = malicious_actor.view_account().await?.balance; println!("BALANCE BEFORE: {}", malicous_account_before.as_near()); let outcome = chef.call(pot_contract.id(), "chef_set_payouts").args_json(json!({"payouts": vec![PayoutInput{ project_id: malicious_actor.id().as_str().parse()?, amount: NearToken::from_near(3).as_yoctonear().into(), }, PayoutInput{ project_id: malicious_actor.id().as_str().parse()?, amount: NearToken::from_near(2).as_yoctonear().into(), }, PayoutInput{ project_id: malicious_actor.id().as_str().parse()?, amount: NearToken::from_near(1).as_yoctonear().into(), }]})).max_gas().transact().await?.into_result()?; println!("OUTCOME LOGS: {:#?}", outcome.logs()); let outcome = admin .batch(pot_contract.id()) .call( Function::new("admin_process_payouts").gas(NearGas::from_tgas(100)), ) .call( Function::new("admin_process_payouts").gas(NearGas::from_tgas(100)), ) .transact() .await?; println!("OUTCOME LOGS: {:#?}", outcome); let malicous_account_after = malicious_actor.view_account().await?.balance; println!("BALANCE AFTER: {}", malicous_account_after.as_near()); assert_eq!( malicous_account_after.as_near(), malicous_account_before.as_near() + NearToken::from_near(12).as_near() ); Ok(())} Recommendation
Consider setting paid_at outside of the callback.
Remediation
The Potlock team has fixed the issue by setting paid_at outside of the callback in this Pull Request: https:/
GUV-7: Max Project Check Is Invalid
Description
We noticed that the assert_max_projects_not_reached check in handle_apply only verifies the maximum number of approved projects. However, as none of the projects in handle_apply are approved, this check is ineffective.
pub(crate) fn handle_apply( &mut self, project_id: ProjectId, message: Option<String>, deposit: Balance, ) -> Application { // check that application doesn't already exist for this project if self.applications_by_id.get(&project_id).is_some() { // application already exists env::panic_str("Application already exists for this project"); } // check that application period is open self.assert_application_period_open(); // check that max_projects hasn't been reached self.assert_max_projects_not_reached(); let application = Application { project_id, message, status: ApplicationStatus::Pending, submitted_at: env::block_timestamp_ms(), updated_at: None, review_notes: None, }; pub(crate) fn assert_max_projects_not_reached(&self) { assert!( self.approved_application_ids.len() < self.max_projects.into(), "Max projects reached" ); } Here is the poc which demonstrates this issue
#[tokio::test]async fn can_set_more_than_max_projects() -> color_eyre::Result<()> { let EnvData { pot_contract, malicious_actor, malicious_actor2, .. } = prepare().await?; malicious_actor .call(pot_contract.id(), "apply") .args_json(json!({})) .max_gas() .deposit(NearToken::from_near(1)) .transact() .await? .into_result()?; Here is the poc which demonstrates this issue
malicious_actor2 .call(pot_contract.id(), "apply") .args_json(json!({})) .max_gas() .deposit(NearToken::from_near(1)) .transact() .await? .into_result()?; let applicaitons = pot_contract .view("get_applications") .args_json(json!({})) .await? .json::<Vec<Application>>()?; println!("Applications: {:?}", applicaitons); assert_eq!(applicaitons.len(), 2); Ok(())} Recommendation
If we aim to limit the number of unapproved applications, we should sort for unapproved ones. Conversely, if we need to limit the number of approved applications, the check should be integrated intochef_set_application_status.
Remediation
The Potlock team has fixed the issue by moving the check to chef_set_application_status in this Pull Request: https:/
GUV-8: Deactivating Provider Does Not Remove It From Default Providers
Description
We've noticed that deactivating a provider doesn't remove it from the default provider list. There might be situations where the provider could be added to the default_provider list. In such cases, the admin must first call admin_deactivate_provider and then reset all default providers. The process would be more efficient if a check was included in the admin_deactivate_provider function to delete the provider from the default_provider set (if it exists).
#[payable] pub fn admin_deactivate_provider( &mut self, provider_id: ProviderId, ) -> Provider { self.assert_owner_or_admin(); // check that provider exists if let Some(versioned_provider) = self.providers_by_id.get(&provider_id) { // update provider let mut provider = Provider::from(versioned_provider); provider.is_active = false; self.providers_by_id.insert( &provider_id, &VersionedProvider::Current(provider.clone()), ); provider } else { env::panic_str("Provider does not exist"); } } Recommendation
Consider removing the deactivated provider from default_provider list (if present).
Remediation
The Potlock team has fixed the issue by removing the deactivated provider from default_provider list in this Pull Request: https:/
GUV-9: Deactivated Provider Retains All Stamps
Description
We noticed that users retain their stamps even after the provider is deactivated. All user stamps should be removed once the provider is deactivated.
#[payable] pub fn admin_deactivate_provider( &mut self, provider_id: ProviderId, ) -> Provider { self.assert_owner_or_admin(); // check that provider exists if let Some(versioned_provider) = self.providers_by_id.get(&provider_id) { // update provider let mut provider = Provider::from(versioned_provider); provider.is_active = false; self.providers_by_id.insert( &provider_id, &VersionedProvider::Current(provider.clone()), ); provider } else { env::panic_str("Provider does not exist"); } } Recommendation
Consider removing all stamps from that provider.
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
GUV-10: Invalid Implementation Of New Admin Addition
Description
We've observed that the 'owner_set_admins' and 'owner_remove_admins' functions both remove admins. This is an incorrect implementation, as 'owner_set_admins' should be setting admins.
pub fn owner_set_admins(&mut self, account_ids: Vec<AccountId>) { self.assert_owner(); for account_id in account_ids { self.admins.remove(&account_id); implementation }} Recommendation
In owner_set_admins function 'remove' should be changed to the 'insert' method.
Remediation
The Potlock team has fixed the issue by changing 'remove' to 'insert' in this Pull Request: https:/
GUV-11: Error-Prone Function Should Be Removed
Description
We believe that admin_dangerously_set_pot_config function should not exist due to potential misuse and a high risk of errors. The current implementation has separate functions to adjust the pot configuration, eliminating the need for bulk edits. A single error in constructing a transaction could have immediate repercussions on the system.
Recommendation
Consider removing this function.
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
GUV-12: Missing Limits On View Functions
Description
We found that the view functions do not have any limitations. If the provider set becomes too large, it could result in an infinite loop situation, causing a panic in the transaction.
pub fn get_providers(&self) -> Vec<ProviderExternal> { self.providers_by_id .iter() .map(|(provider_id, provider)| { ProviderExternal::from_provider_id( &provider_id.0, Provider::from(provider), ) }) .collect() } pub fn get_default_providers(&self) -> Vec<ProviderExternal> { self.default_provider_ids .iter() .map(|provider_id| { ProviderExternal::from_provider_id( &provider_id.0, Provider::from( self.providers_by_id.get(&provider_id).unwrap(), ),)}).collect()} Recommendation
Consider setting limits on the number of entries that can be fetched at once.
Remediation
The Potlock team has partially fixed the issue by adding limits to get_providers in this Pull Request: https:/
GUV-13: Privileged Functions Are Not Protected By 2FA
Description
We noticed that none of the privileged functions are protected by two-factor authentication (2FA). In the NEAR scenario, 2FA prompts a user to sign a transaction if a deposit is attached to it. There are two types of access keys: Full and Function. Function access keys are primarily used by front-ends to perform actions on behalf of the user without constantly prompting them to sign a transaction. However, if the front-end is compromised, a malicious actor might exploit the function access key to attack the protocol. If the function access key is added to an admin or owner account, the malicious actor could potentially perform actions within the protocol on their behalf.
Recommendation
Consider adding assert_one_yocto to all privileged functions. This prevents the use of function access keys to call those functions.
Remediation
The Potlock team has fixed the issue by adding asserting at least one yocto attached in all major privileged functions in this Pull Request: https:/
GUV-14: Privileged Users Should Not Be Allowed To Have Other Roles
Description
We noted that privileged accounts like the owner, admin, and chef are currently permitted to register as a project in the registry contract, apply as a project in the pot contract, and register as a provider in the sybil contract. This allowance for privileged roles to perform these actions escalates the risk of malicious misuse.
Recommendation
Consider restricting those functions to only regular users.
Remediation
The Potlock team has partially fixed the issue by restricting only application to the pot by chef/admin/owner in this Pull Request: https:/
GUV-15: Redundant Functionality
Description
We found that the provider flagging feature is unnecessary, as it doesn't prevent the use of the provider. If a provider misbehaves, the admin can simply deactivate them. This function only takes up space, thereby increasing the size of the contract.
#[payable] pub fn admin_flag_provider(&mut self, provider_id: ProviderId) -> Provider { self.assert_owner_or_admin(); // check that provider exists if let Some(versioned_provider) = self.providers_by_id.get(&provider_id) { // update provider let mut provider = Provider::from(versioned_provider); provider.is_flagged = true; self.providers_by_id.insert( &provider_id, &VersionedProvider::Current(provider.clone()), ); provider } else {env::panic_str("Provider does not exist");}} Recommendation
Consider removing this function
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
GUV-16: Size Of The Contract Can Be Decreased
Description
We noticed that the crate type is set as both cdylib and rlib. However, since NEAR smart contracts are compiled to WASM, rlib is unnecessary. Eliminating rlib can significantly reduce the size of the generated WASM binary.
[lib]crate-type = ["cdylib", "rlib"] Recommendation
Consider removing rlib from Cargo.toml.
Remediation
The Potlock team has fixed the issue by removing rlib from Cargo.toml in this Pull Request: https:/
GUV-17: Redundant State Statuses
Description
We noted that, within the logic of the pot smart contract, only the application statuses 'Approved' and 'Pending' are utilized. Furthermore, only the 'Approved' status is used for the project.
Recommendation
Consider removing redundant statuses
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
GUV-18: Usage of assert instead of require
Description
We noted that the protocol often uses assert! instead of require!. Assert generates additional data such as the file and line number, which isn't necessary in a smart contract setting. This results in unnecessary bloat.
Recommendation
Replace all assert! macros with require!.
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
GUV-19: Using suboptimal panic function
Description
We've noted that the panic! macro is frequently used in many functions within the protocol. This macro generates additional debug information such as file and line number, which may not be necessary in a smart contract scenario
Recommendation
Consider using env::panic_str. It does not produce additional info apart from passed error message
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
GUV-20: Redundant state check in initializers
Description
We noticed that many initializers in the protocol use redundant state checks - 'assert! (!env::state_exists(), "Already initialized");'.
Recommendation
Consider removing the state check, as the #[init] macro already handles it.
Remediation
The Potlock team has acknowledged the issue and might fix it in the upcoming release.
The web version and the PDF (38 pages) are generated from the same source. The report as first delivered is kept in the public reports archive.