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

Work & reportsReport

Sweat EconomySweat Booster & Step Jars Security Review

Guvenkaya reviewed the Sweat Booster smart contract and the step jars feature in the Sweat Jar smart contract on NEAR.

Sweat Economy logo

Date of engagement: 19th August 2024 - 28th August 2024

  • Timur GüvenkayaPrincipal advisor

Engagement Dashboard

Audit results

Guvenkaya conducted a security assessment of the Sweat Booster smart contract and the step jars feature inside the Sweat Jar smart contract. During this engagement, 7 findings were reported. 2 were Critical, 3 High, and 2 Medium severity. The Sweat Foundation team has fixed all issues.

Engagement record

Client
The Sweat Foundation Ltd
System
Sweat Booster and Sweat Jar contracts on NEAR
Review period
19th August 2024 - 28th August 2024
Paths in scope
29
Principal advisor
Timur Güvenkaya

Timeline

  1. Review start19th August 2024
  2. Draft report28th August 2024
  3. Final report19th December 2024

Findings by severity

  • Critical 2
  • High 3
  • Medium 2
  • Low 0
  • Informational 0

Finding status at final report

7Raised
7Fixed

Remediation complexity

4Simple
1Moderate
2Complex

Impact

5Severe
2Moderate

Likelihood

2Likely
5Possible

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

The Sweat Foundation is an organization behind Sweat Economy, an innovative project at the intersection of fitness and crypto. It motivates users to stay active by converting their steps into SWEAT Token. This approach promotes health and fitness and works as an entry point to crypto for many users.

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. 29 paths are in scope. Each path links to the file at the reviewed commit.

PathReview depthScope note
contract/src/redeem/api.rsDeepSweat Booster Contract
contract/src/common/nft_interface.rsDeepSweat Booster Contract
contract/src/mint/model.rsDeepSweat Booster Contract
contract/src/burn/api.rsDeepSweat Booster Contract
contract/src/init/api.rsDeepSweat Booster Contract
contract/src/mint/api.rsDeepSweat Booster Contract
contract/src/common/asserts.rsDeepSweat Booster Contract
contract/src/common/ft_interface.rsDeepSweat Booster Contract
contract/src/auth/api.rsDeepSweat Booster Contract
contract/src/lib.rsDeepSweat Booster Contract
contract/src/config/api.rsDeepSweat Booster Contract
contract/src/init/model.rsDeepSweat Booster Contract
contract/src/common/mod.rsDeepSweat Booster Contract
contract/src/mint/mod.rsDeepSweat Booster Contract
contract/src/init/mod.rsDeepSweat Booster Contract
contract/src/redeem/mod.rsDeepSweat Booster Contract
contract/src/score/account_score.rsDeepSweat Jars Contract
contract/src/score/charts.rsDeepSweat Jars Contract
contract/src/score/api.rsDeepSweat Jars Contract
contract/src/score/mod.rsDeepSweat Jars Contract
contract/src/migration/step_jars.rsDeepSweat Jars Contract
contract/src/claim/api.rsDeepSweat Jars Contract
contract/src/withdraw/api.rsDeepSweat Jars Contract
model/src/udecimal.rsDeepSweat Jars Contract
model/src/timezone/timestamps.rsDeepSweat Jars Contract
model/src/timezone/timezone.rsDeepSweat Jars Contract
model/src/score.rsDeepSweat Jars Contract
model/src/timezone/mod.rsDeepSweat Jars Contract
contract/src/jar/model/common.rsDeepSweat Jars Contract

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. Only the changes related to the step jars are in scope for the Sweat Jar contract.

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

  1. 01 Likelihood Likely, possible or rare
  2. 02 Impact Severe, moderate or negligible
  3. 03 Severity Derived from the matrix below
  4. 04 Remediation complexity Simple, moderate or difficult
  5. 05 Status Not fixed, fixed, acknowledged or scheduled

Impact

ImpactWhat it means here
SevereExploitation could result in critical loss or compromise, such as full system control, substantial financial loss, or severe reputational damage.
ModerateExploitation may lead to limited data loss, partial compromise, moderate financial impact, or noticeable degradation of services.
NegligibleExploitation has minimal impact, such as minor data exposure without significant consequences or slight inconvenience without substantial disruption.

Likelihood

LikelihoodWhat it means here
LikelyThe vulnerability is easily discoverable and not overly complex to exploit.
PossibleThe vulnerability presents some challenges either in discovery or in the complexity of the attack.
RareThe 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 EaseObviousConcealedHidden
ComplexPossibleRareRare
ModerateLikelyPossibleRare
StraightforwardLikelyPossiblePossible

Severity

SeverityWhat it means here
CriticalAssigned to vulnerabilities with severe impact and a likely likelihood of exploitation.
HighFor vulnerabilities with either severe impact but only a possible likelihood, or moderate impact with a likely likelihood.
MediumUsed for vulnerabilities with severe impact but a rare likelihood, moderate impact with a possible likelihood, or negligible impact with a likely likelihood.
LowFor vulnerabilities with moderate impact and rare likelihood, or negligible impact with a possible likelihood.
InformationalThe lowest severity rating, typically for vulnerabilities with negligible impact and a rare likelihood of exploitation.
Likelihood \ ImpactSevereModerateNegligible
LikelyCriticalHighMedium
PossibleHighMediumLow
RareMediumLowInformational

Remediation complexity

This measures how difficult it is to remediate a finding once it has been identified.

Remediation complexityWhat it means here
SimplePatches or fixes are readily available and easily implemented.
ModerateRequires some time and resources to remediate, but well within the capabilities of most organizations.
DifficultRemediation 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.

StatusWhat it means here
Not FixedIndicates 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.
FixedThis 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.
AcknowledgedThis 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.
ScheduledThis 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

7All findings
2 Critical
3 High
2 Medium
0 Low
0 Informational

Findings register

FindingImpactLikelihoodSeverityRemediation complexityStatus
GUV-1: Missing Access Control On NFT Transfer ResolverSevereLikelyCriticalSimpleFixed
GUV-2: Missing Access Control On Account Score RecordingSevereLikelyCriticalSimpleFixed
GUV-3: Per User DoS Of The Claim & Withdraw FunctionalitySeverePossibleHighComplexFixed
GUV-4: DoS Of Record Score FunctionalitySeverePossibleHighComplexFixed
GUV-5: Inconsistent State After NFT BurnSeverePossibleHighModerateFixed
GUV-6: Possible Scam Scenario Via Race ConditionModeratePossibleMediumSimpleFixed
GUV-7: Score Is Not Reverted After Failed TransferModeratePossibleMediumSimpleFixed

Detailed Findings

GUV-1: Missing Access Control On NFT Transfer Resolver

Severity
Critical
Impact
Severe
Likelihood
Likely
Remediation Complexity
Simple
Status
Fixed
Fix commit
fe94acf23ec7af8bd5d0ae26ec7db75af36f47dc

Description

We observed that nft_resolve_transfer function lacks any access control. By using this function, a malicious actor can steal NFTs from any account.

common:nft_resolve_transfer:common/nft_interface.rs
#[near]impl NonFungibleTokenResolver for Contract {    fn nft_resolve_transfer(        &mut self,        previous_owner_id: AccountId,        receiver_id: AccountId,        token_id: TokenId,        approved_account_ids: Option<HashMap<AccountId, u64>>,    ) -> bool {        self.tokens            .nft_resolve_transfer(previous_owner_id, receiver_id, token_id, approved_account_ids)}}

Recommendation

Consider adding the #[private] macro on top of the function to make it only callable by the contract itself.

Remediation

The Sweat Foundation team has fixed the issue by adding the #[private] macro on top of the function in this commit: fe94acf23ec7af8bd5d0ae26ec7db75af36f47dc

GUV-2: Missing Access Control On Account Score Recording

Severity
Critical
Impact
Severe
Likelihood
Likely
Remediation Complexity
Simple
Status
Fixed
Fix commit
f09a5d482e02726ffaa38f4f5b1bbfaf8c3261ad

Description

We observed that record_score function lacks any access control. By using this function, a malicious actor can put any score to its account and then claim the reward.

score:record_score:contract/src/score/api.rs
#[near_bindgen]impl ScoreApi for Contract {    fn record_score(&mut self, batch: Vec<(AccountId, Vec<(Score, UTC)>)>) {        let mut event = vec![];         let now = block_timestamp_ms();         for (account, new_score) in batch {            self.migrate_account_jars_if_needed(&account);            ...            }  }

Recommendation

Consider asserting that only manager can call this function.

Remediation

The Sweat Foundation team has fixed the issue by asserting whether the caller is the manager in this commit: f09a5d482e02726ffaa38f4f5b1bbfaf8c3261ad

GUV-3: Per User DoS Of The Claim & Withdraw Functionality

Severity
High
Impact
Severe
Likelihood
Possible
Remediation Complexity
Complex
Status
Fixed

Description

It was observed that it is possible for a malicious actor to trigger DoS of claim and withdraw functionality by locking jars of other users. During the withdrawal and claiming process, account jars are temporarily locked to prevent race condition issues. When callback is reached, the lock is released. However, if there was a panic in the callback function, the lock would not be released, causing jars to be locked forever. In return, it won't allow users to claim or withdraw from them. In the current implementation, it is possible to get the panic in the callback by reaching the log limit since at the end of the function the log is emitted containing all of the jars that were processed. In addition, it is possible to create a jar for someone else and there are no limit in the number of jars that can be created per account. These scenarios open up the possibility of this attack on each user:

  • 1. 1. Pick a victim account. 2. 2. Create a ticket with the receiver and get the signature. 3. 3.Do ft_transfer_call with a staking message to create a jar for receiver. 4. 4. Repeat 2 and 3 until you create around 470 jars (enough to cause log limit) 5. 5. Wait until the user tries to claim or withdraw all 6. 6. Now all of the victim's jars are locked

PoC

poc:per_user_dos
    #[tokio::test]async fn per_user_dos() -> anyhow::Result<()> {    let mut context = prepare_contract(        None,        [            RegisterProductCommand::Locked12Months12Percents,            RegisterProductCommand::Locked6Months6Percents,            RegisterProductCommand::Locked6Months6PercentsWithWithdrawFee,            RegisterProductCommand::Locked5Minutes60000Percents,        ],    )    .await?;
poc:per_user_dos (continued)
let alice = context.alice().await?;let manager = context.manager().await?;let products = context.sweat_jar().get_products().await?;assert_eq!(4, products.len()); context    .sweat_jar()    .create_jar(        &alice,        RegisterProductCommand::Locked12Months12Percents.id(),        1_000_000,        &context.ft_contract(),    )    .await?;let alice_principal = context.sweat_jar().get_total_principal(alice.to_near()).await?;let alice_interest = context.sweat_jar().get_total_interest(alice.to_near()).await?;assert_eq!(1_000_000, alice_principal.total.0);assert_eq!(0, alice_interest.amount.total.0);context.fast_forward_hours(1).await?;context    .sweat_jar()    .bulk_create_jars(        alice.to_near(),        RegisterProductCommand::Locked5Minutes60000Percents.id(),        10000,        500,    )    .with_user(&manager)    .await?;
poc:per_user_dos (continued) (continued)
    context.fast_forward_minutes(5).await?;              context                  .sweat_jar()                  .claim_total(true.into())                  .with_user(&alice)                  .await                  .expect_err("The length of a log message 16440 exceeds the limit 16384");              // CLAIMING AGAIN BUT ALL JARS ARE LOCKED              let claimed_details = context.sweat_jar().claim_total(Some(true)).with_user(&alice).await?;              let ClaimedAmountView::Detailed(claimed_details) = claimed_details else {                  panic!()              };              let claimed_amount = claimed_details.total.0;              assert_eq!(claimed_amount, 0);              // Cannot withdraw              context                  .sweat_jar()                  .withdraw(U32(1), None)                  .with_user(&alice)                  .expect_error("Another operation on this Jar is in progress")                  .await?;              Ok(())          }

Recommendation

We propose switching to having a single jar per product to avoid any types of DoS attacks due to limitless jars.

Remediation

The Sweat Foundation refactored the jars codebase to have a single jar per product in this PR: https://github.com/sweatco/sweat-jar/pull/114

GUV-4: DoS Of Record Score Functionality

Severity
High
Impact
Severe
Likelihood
Possible
Remediation Complexity
Complex
Status
Fixed

Description

We observed the record_score iterates over user jars to then perform interest calculation. However, since there is no limit on the number of jars per account, it is possible for an attacker to create a large number of jars for a single account and then when oracle calls the record_score function it will lead to a panic due to the gas limit. This will cause DoS of record_score every time there is a malicious account in a batch.

score:record_score:contract/src/score/api.rs
for jar in &mut account_jars.jars {    let product = self        .products        .get(&jar.product_id)        .unwrap_or_else(|| env::panic_str(&format!("Product '{}' doesn't exist", jar.product_id)));     if !product.is_score_product() {        continue;    }     let (interest, remainder) = jar.get_interest(&score, &product, now);    ...}

Recommendation

We propose switching to having a single jar per product to avoid any types of DoS attacks due to limitless jars.

Remediation

The Sweat Foundation refactored the jars codebase to have a single jar per product in this PR: https://github.com/sweatco/sweat-jar/pull/114

GUV-5: Inconsistent State After NFT Burn

Severity
High
Impact
Severe
Likelihood
Possible
Remediation Complexity
Moderate
Status
Fixed
Fix commit
ebe5d8012d56907b8db261c301d9699a7916aec2

Description

We observed that the booster smart contract will have an inconsistent state if a user minted more than 1 token and then redeemed/burned. That's because even if a user has more than one token, the whole entry of tokens_per_owner is removed. If minted again, the nested structure will show that it has a length of 1, for instance, even though when we manually call .contains() it will include 2 tokens.

burn:burn:contract/src/burn/api.rs
fn burn(&mut self, token_id: TokenId) -> TokenMetadata {    self.owner_by_id.remove(&token_id).expect("Owner not found");     if let Some(approvals_by_id) = &mut self.approvals_by_id {        approvals_by_id.remove(&token_id);    }     self.token_metadata_by_id        .as_mut()        .and_then(|by_id| by_id.remove(&token_id))        .expect("Token metadata not found")}

PoC

poc:burn_inconsistent_state
    #[test]fn burn_token_inconsistent_state() {    let oracle = oracle();    let mut context = Context::new(&oracle);    let media = "bafkiufjkssmvjby7srbr44ivexsexqvdvjby7hhoobl3q3twim3bgxffrm".to_string();    let media_hash = Base64VecU8::from(b"w5SZrIcpoakfcRUl5EvCa4hy2ZzscFe4bnZDNhNcpYs=".to_vec());    let denomination = U128(2_000_000);    context.switch_account(oracle);    // MINTING 1st TOKEN    context.with_deposit_yocto(DEPOSIT_FOR_MINTING, |context| {        context            .contract()            .mint_balance_booster(alice(), denomination.clone(), media.clone(), media_hash.clone());    });    // MINTING 2nd TOKEN    context.with_deposit_yocto(DEPOSIT_FOR_MINTING, |context| {        context            .contract()            .mint_balance_booster(alice(), denomination.clone(), media.clone(), media_hash.clone());    });    let alice_tokens = context.contract().nft_tokens_for_owner(alice(), None, None);    assert_eq!(2, alice_tokens.len());    let token_to_burn = alice_tokens.first().unwrap();    context.contract().burn(alice(), token_to_burn.token_id.clone());    let burnt_token = context.contract().nft_token(token_to_burn.token_id.clone());    assert!(burnt_token.is_none());
poc:burn_inconsistent_state (continued)
        // NONE EVEN THOUGH WE HAVE 1 UNBURNED TOKEN LEFT        assert!(context            .contract()            .tokens            .tokens_per_owner            .as_ref()            .unwrap()            .get(&alice())            .is_none());        // MINTING ANOTHER ONE        context.with_deposit_yocto(DEPOSIT_FOR_MINTING, |context| {            context                .contract()                .mint_balance_booster(alice(), denomination, media, media_hash);        });        let alice_data = context        .contract()        .tokens        .tokens_per_owner        .as_ref()        .unwrap()        .get(&alice())        .unwrap();    // LEN IS 1 EVEN THOUGH WE HAVE 2 TOKENS    assert_eq!(alice_data.len(), 1);    // CONTAINS 1 AND 2 EVEN THOUGH THE LEN IS 1    assert!(alice_data.contains(&"1".to_string()));    assert!(alice_data.contains(&"2".to_string()));}

Recommendation

We propose removing the outer entry only if the nested entry is empty. Otherwise, the nested entry has to be updated and inserted back.

Remediation

The Sweat Foundation team has fixed the issue by removing the outer entry if the internal one is empty: ebe5d8012d56907b8db261c301d9699a7916aec2

GUV-6: Possible Scam Scenario Via Race Condition

Severity
Medium
Impact
Moderate
Likelihood
Possible
Remediation Complexity
Simple
Status
Fixed
Fix commit
cc4da86d967771c776a70c223d9fd26495484245

Description

We observed that it is possible to perform NFT transfer operations while the redeem process is in progress. After redeem process is done, in the callback the NFT is burned. This situation opens an opportunity for a malicious actor to scam another user/system.

Example: Alice wants to redeem SWEAT voucher in the third party and receive any other token in return. That third party maybe reselling voucher or doing something else with that NFT (NFT is needed for them)

  • 1. 1. Alice initiates a call to redeem the NFT voucher. 2. 2. At the same time, Alice calls nft_transfer to transfer NFT to the third party. 3. 3.Alice receives tokens or something else in return from the third party 4. 4. Callback is reached after 2 blocks and that NFT is burned from the third party.

Result: Alice received something from the third party but another side received nothing since their NFT got burned.

Recommendation

We propose checking whether the redeem is in progress inside of the nft_transfer and the nft_transfer_call.

Remediation

The Sweat Foundation team has fixed the issue by including a check which prevents calling nft transfer function during the redeem process: cc4da86d967771c776a70c223d9fd26495484245

GUV-7: Score Is Not Reverted After Failed Transfer

Severity
Medium
Impact
Moderate
Likelihood
Possible
Remediation Complexity
Simple
Status
Fixed
Fix commit
6967bccb40eaa8f5b36494f99c2408ad359f0b49

Description

We observed that in the claim_jars_internal, the score is set to zero after fetching the score to get the interest. After the transfer is done, if the transfer fails, the score is not reverted to the previous value inside of the after_claim_internal callback. If a user tries to repeat the same operation, the user score will remain zero, which leads to the incorrect interest calculation for that user.

claim:claim_jars_internal:contract/src/claim/api.rs
fn claim_jars_internal(    &mut self,    account_id: AccountId,    jar_ids: Vec<JarIdView>,    detailed: Option<bool>,) -> PromiseOrValue<ClaimedAmountView> {    let now = env::block_timestamp_ms();    let mut accumulator = ClaimedAmountView::new(detailed);     let unlocked_jars: Vec<Jar> = self        .account_jars(&account_id)        .iter()        .filter(|jar| !jar.is_pending_withdraw && jar_ids.contains(&U32(jar.id)))        .cloned()        .collect();     let mut event_data: Vec<ClaimEventItem> = vec![];     let account_score = self.account_score.get_mut(&account_id);     let score = account_score.map(AccountScore::claim_score).unwrap_or_default();     for jar in &unlocked_jars {                ...
score:claim_score:contract/src/score/account_score.rs
/// On claim we need to clear active scores so they aren't claimed twice or more.pub fn claim_score(&mut self) -> Vec<Score> {    let today = self.timezone.today();     let result = if today == self.update_day() {        let score = self.scores[1];        self.scores[1] = 0;        vec![score]    } else {        let score = vec![self.scores[0], self.scores[1]];        self.scores[0] = 0;        self.scores[1] = 0;        score    };     self.updated = block_timestamp_ms().into();     result}

Recommendation

We propose reverting the state of the score to the previous value in the after_claim_internal.

Remediation

The Sweat Foundation team has fixed the issue by reverting the score in the callback: 6967bccb40eaa8f5b36494f99c2408ad359f0b49

The web version and the PDF (21 pages) are generated from the same source. The report as first delivered is kept in the public reports archive.

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