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

Sweat Economy Defer: Locks, Batch Limits, and Rounding

Published

Defer NEAR Rust Smart Contract Security Assessment

Sweat Economy converts step counts into SWEAT tokens. We reviewed the Defer feature of their NEAR Rust contracts. Four findings came out of it: three low severity and one informational. The findings show how production settings, state changes, and callback access can expose gaps that smaller tests miss.

Read the full security report.

GUV-1 Race Condition Lock Not Asserted - Low Severity

Observation

The claim function uses the is_locked field on AccountRecord to guard against double spending: once an account has a transaction in flight, it should not be able to start another. But the function sets the lock without ever asserting it first, so the guard never actually blocks anything.

Impact

We could not produce a race condition. Accruals are cleared before the cross-contract call, so there is no balance left to claim a second time. We tried batch calls through near_workspaces and direct contract interactions, and neither reproduced it.

So this is low severity rather than high: the lock does not work, but a second mechanism happens to cover for it. That is a fine position to be in right up until someone reorders those two operations.

Code Snippet

From sweat-claim:claim:contract/src/claim/api.rs, with no assertion before the lock is set:

api.rs

fn claim(&mut self) -> PromiseOrValue<ClaimResultView> {
    let account_id = env::predecessor_account_id();
    ...
    let account_data = self.accounts.get_mut(&account_id).expect("Account data is not found");
    account_data.is_locked = true;
    ...
}

Proposed Solution

Assert is_locked at the top of claim, before anything else runs. The field is already there; it just needs to be checked before it is set.

Takeaway

A lock that is set but never checked is not a lock. If you add one, add the assertion in the same commit, because the next person to read the code will assume the guard works.

Remediation

The Sweat Foundation added the assertion at the start of the claim function, so a second operation on the same account now fails instead of proceeding.

Updated Code Snippet

let account_data = self.accounts.get_mut(&account_id).expect("Account data is not found");

// add assert preventing double spending to claim method
require!(!account_data.is_locked, "Another operation is running");

account_data.is_locked = true;

let now = now_seconds();

That closes the finding, and it also means the lock now does what a reader of the code would assume it does.

GUV-2 Defer Batch Might Fail Under Production Batch Size - Low Severity

The defer batch operation can fail at the production batch size of 135. The function logs every transaction in the batch, and with long enough account IDs the log exceeds the size limit and the call fails.

Our own tests did not surface this at first, because we had picked the batch size ourselves. It only appeared once we asked the team what size they actually run in production. The initial tests missed the production batch size.

Impact

A batch of 135 entries with 62-character account IDs exceeded the log size limit in our test. The existing tests did not cover that production batch size.

The question that found it

We asked the team for its production batch size, then tested it with long account IDs. Ask for production limits early so the tests exercise the conditions the system actually faces.

Code Snippet:

sweat_claim:record_batch_for_hold:contract/src/record/api.rs

let mut event_data = RecordData { timestamp: now_seconds, amounts: vec![], };
for (account_id, amount) in amounts {
    event_data.amounts.push((account_id.clone(), amount));
    let amount = amount.0; let index = balances.len();
    total_balance += amount; balances.push(amount); ...
    emit(EventKind::Record(event_data));
}

Proposed Solution

Test at the batch size you actually run, with account IDs as long as the ones you actually see.

Takeaway

Put the production numbers in the test suite. A batch test that uses a convenient round number proves the code works on a convenient round number.

Remediation

The Sweat Foundation added a test at the real production batch size, which now covers the log size limit.

GUV-3 Suboptimal Rounding Direction For An Oracle Fee - Low Severity

The oracle fee rounds down. For any sweat_to_mint value below 20 that produces a fee of 0, the protocol collects nothing. Each instance is worth almost nothing; the concern is that they accumulate in one direction and never the other.

Code Snippet

pub(crate) fn calculate_tokens_amount(&self, steps: u32) -> (u128, u128) {
    let sweat_to_mint: u128 = self.formula(self.steps_since_tge, steps).0;
    let trx_oracle_fee: u128 = sweat_to_mint * 5 / 100;
    let minted_to_user: u128 = sweat_to_mint - trx_oracle_fee;
    (minted_to_user, trx_oracle_fee)
}

SWEAT uses 18 decimals, so a sweat_to_mint under 20 is rare in practice. We still flagged it, because rounding that always favours the same side of a trade is worth correcting while it is cheap to correct.

Proposed Solution

Use div_ceil for the oracle fee so it rounds up rather than down.

Takeaway

Decide which way every rounding operation should go, and write down why. Rounding is not a detail in a contract that handles money, it is a policy, and the default direction is rarely the one you would choose deliberately.

Remediation

The Sweat Foundation switched the oracle fee to div_ceil in commit e208f3a06050637c6477c73e06cd69a241fe5d7d.

GUV-4 Usage Of Custom Check Instead Of #[private] In The Callback - Informational

The on_record callback checks whether the transaction signer is an oracle. The intended restriction is different: this callback should only be called by the contract itself.

Code Snippet

fn on_record(&mut self, receiver_id: AccountId, amount: U128, fee_account_id: AccountId, fee: U128) {
    if !self.oracles.contains(&env::signer_account_id()) {
        panic_str("The operation can be only initiated by an oracle");
    }
    if !is_promise_success() {
        panic_str("Failed to record data in holding account");
    }
    ...
}

Proposed Solution

Use #[private] to restrict the callback to calls from the contract itself. This replaces the signer-is-oracle check with the intended contract-only access rule.

Takeaway

Distinguish the original transaction signer from the account making the immediate call. Test that callbacks reject direct calls from accounts that should not invoke them.

Remediation

The Sweat Foundation replaced the custom check with the #[private] macro.

What we took away

The batch-size finding is a reminder to test production conditions. Ask what limits the team actually uses, then exercise those limits with realistic input sizes.

Thanks to the Sweat Foundation, who fixed all four and let us publish them.

Discuss a smart contract security review.

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