#37660 [SC-High] incorrect tracking of `TOTAL_ACTIVE_STAKE` leads to permanent freezing of funds

Submitted on Dec 11th 2024 at 21:56:47 UTC by @A2Security for Audit Comp | Folks: Liquid Staking

  • Report ID: #37660

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/Folks-Finance/algo-liquid-staking-contracts/blob/8bd890fde7981335e9b042a99db432e327681e1a/contracts/xalgo/consensus_v2.py

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

There is a flow in the internal accounting for tracking the total active amount of staked Algo in the state variable TOTAL_ACTIVE_STAKE, this state variable is only incremented by the amount of algo deposited (increased in claim_delayed_mint() and immediate_mint()) when minting xAlgo.

The bug however arises from the fact that in the burn() function we reduce the TOTAL_ACTIVE_STAKE by the amount of the algo recieved from burning the xAlgo.

To simplify the bug:

  • User A deposits 100 ALGO recieves 100 xALGO, TOTAL_ACTIVE_STAKE = 100 ALGO

  • After a year the user, accrues some rewards 10 ALGO so the XALGO he owns is now worth 110 ALGO

  • when the user will try to burn hi 100 xAlgo, he will fail because in burn() we will try to reduce TOTAL_ACTIVE_STAKE (100 ALGO) by 110 ALGO which will underflow. (10 ALGO are locked in the contract)

Vulnerability Details

The vulnerability arises from the fact that Total Active Stake is not tracked correctly leading to an undersestimation which wil result in an underflow in the burn() function.

The TOTAL_ACTIVE_STAKE is only updated in the immediate_mint() and claim_delayed_mint() functions.

def claim_delayed_mint(receiver: abi.Address, nonce: abi.StaticBytes[L[2]]) -> Expr:
    box_name = Concat(DelayMintBox.NAME_PREFIX, receiver.get(), nonce.get())
    box = BoxGet(box_name)

    delay_mint_receiver = Extract(box.value(), DelayMintBox.RECEIVER, Int(32))
    delay_mint_stake = ExtractUint64(box.value(), DelayMintBox.STAKE)
    delay_mint_round = ExtractUint64(box.value(), DelayMintBox.ROUND)

    algo_balance = ScratchVar(TealType.uint64)
    mint_amount = ScratchVar(TealType.uint64)

    return Seq(
        # callable by anyone
        rekey_and_close_to_check(),
        # ensure initialised
        Assert(App.globalGet(initialised_key)),
        # check nonce is 2 bytes
        Assert(Len(nonce.get()) == Int(2)),
        # check box
        box,
        Assert(box.hasValue()),
        Assert(receiver.get() == delay_mint_receiver),
        Assert(Global.round() >= delay_mint_round),
        # update total stake and total rewards
        App.globalPut(total_pending_stake_key, App.globalGet(total_pending_stake_key) - delay_mint_stake),
@>>        App.globalPut(total_active_stake_key, App.globalGet(total_active_stake_key) + delay_mint_stake),

The problem however arises because we don't increase the TOTAL_ACTIVE_STAKE when we sync rewards and unclaimed fees for the protocol. This leads to underestimating the TOTAL_ACTIVE_STAKE by the accumalted amounts

This will lead to the actual withdrawable assets of users to be significantly higher than the value stored in the state variable TOTAL_ACTIVE_STAKE. Which will lead to an underflow in the burn() function

def burn(send_xalgo: abi.AssetTransferTransaction, min_received: abi.Uint64) -> Expr:
    burn_amount = send_xalgo.get().asset_amount()
    algo_balance = ScratchVar(TealType.uint64)
    algo_to_send = ScratchVar(TealType.uint64)

    return Seq(
        rekey_and_close_to_check(),
        # ensure initialised
        Assert(App.globalGet(initialised_key)),
        # check xALGO sent
        check_x_algo_sent(send_xalgo),
        # update total rewards
        update_total_rewards_and_unclaimed_fees(),
        # calculate algo amount to send
        algo_balance.store(
            App.globalGet(total_active_stake_key)
            + App.globalGet(total_rewards_key)
            - App.globalGet(total_unclaimed_fees_key)
        ),
        algo_to_send.store(
            mul_scale(
                burn_amount,
                algo_balance.load(),
                get_x_algo_circulating_supply() + burn_amount
            )
        ),
        # check amount and send ALGO to user
        Assert(algo_to_send.load()),
        Assert(algo_to_send.load() >= min_received.get()),
       send_algo_from_proposers(Txn.sender(), algo_to_send.load()),
        # @audit underflow here
@>>        App.globalPut(total_active_stake_key, App.globalGet(total_active_stake_key) - algo_to_send.load()),

N.B algo to

Impact Details

  • Permanent locked funds for the user who withdraws last.

A simplified example to showcase this vulnerability. 2 users A and B.

  • User A deposits 100 ALGO recieves 100 xALGO, TOTAL_ACTIVE_STAKE = 100 ALGO w8 a year and his 100 xAlgo will be worth 110 ALGO

  • User B deposits 10 ALGO recieves ~8 xALGO, TOTAL_ACTIVE_STAKE = 110 ALGO (100 from User A and 10 from User B)

  • User A burns his xAlgo (worth 110 ALGO), TOTAL_ACTIVE_STAKE = 110 - 110 = 0 ALGO

  • User B can't withdraw his ALGO because when he tries to withdraw reducing 10 from 0 will underflow.

Proof of Concept

Proof of Concept

xpected Result:

> algo-liquid-staking-contracts@0.0.1 test
> PYTHONPATH='./contracts' jest --runInBand

  console.log
    totoal pending stake 0n

      at Object.log (test/xAlgoConsensusV2.test.ts:1590:15)

  console.log
    total active stake 182352954n

      at Object.log (test/xAlgoConsensusV2.test.ts:1591:15)

  console.log
    total unclaimed fees 0n

      at Object.log (test/xAlgoConsensusV2.test.ts:1592:15)

  console.log
    0n 182352954n 63000000n 0n

      at Object.log (test/xAlgoConsensusV2.test.ts:1593:15)

  console.log
    proposers algo balance 245352954n

      at Object.log (test/xAlgoConsensusV2.test.ts:1597:15)

  console.log
    unclaimable balance 63000000n

      at Object.log (test/xAlgoConsensusV2.test.ts:1599:15)

Please add the following modifications:

diff --git a/test/xAlgoConsensusV2.test.ts b/test/xAlgoConsensusV2.test.ts
index 662887b..563887e 100644
--- a/test/xAlgoConsensusV2.test.ts
+++ b/test/xAlgoConsensusV2.test.ts
@@ -1528,6 +1528,45 @@ describe("Algo Consensus V2", () => {
   });
 
   describe("claim fee", () => {
+    // test("succeeds", async () => {
+    //   // airdrop 10 ALGO rewards (%fee of which will be claimable by admin)
+    //   const additionalRewards = BigInt(10e6);
+    //   await fundAccountWithAlgo(algodClient, proposer0.addr, additionalRewards / BigInt(2), await getParams(algodClient));
+    //   await fundAccountWithAlgo(algodClient, proposer1.addr, additionalRewards / BigInt(2), await getParams(algodClient));
+    //   const additionalRewardsFee = mulScale(additionalRewards, fee, ONE_4_DP);
+
+    //   // balances before
+    //   const adminAlgoBalanceB = await getAlgoBalance(algodClient, admin.addr);
+    //   const { algoBalance: oldAlgoBalance, xAlgoCirculatingSupply: oldXAlgoCirculatingSupply, proposersBalances: oldProposersBalance } = await getXAlgoRate();
+
+    //   // state before
+    //   let state = await parseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);
+    //   const { totalRewards: oldTotalRewards, totalUnclaimedFees: oldTotalUnclaimedFees } = state;
+
+    //   // claim fee
+    //   const proposerAddrs = [proposer0.addr, proposer1.addr];
+    //   const tx = prepareClaimXAlgoConsensusV2Fee(xAlgoConsensusABI, xAlgoAppId, user1.addr, admin.addr, proposerAddrs, await getParams(algodClient));
+    //   const txId = await submitTransaction(algodClient, tx, user1.sk);
+    //   const txInfo = await algodClient.pendingTransactionInformation(txId).do();
+    //   const { txn: transfer } = txInfo['inner-txns'][txInfo['inner-txns'].length - 1].txn;
+    //   state = await parseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);
+    //   expect(state.totalRewards).toEqual(oldTotalRewards + additionalRewards - (oldTotalUnclaimedFees + additionalRewardsFee));
+    //   expect(state.totalUnclaimedFees).toEqual(BigInt(0));
+
+    //   // balances after
+    //   const adminAlgoBalanceA = await getAlgoBalance(algodClient, admin.addr);
+    //   const { algoBalance, xAlgoCirculatingSupply, proposersBalances } = await getXAlgoRate();
+    //   expect(adminAlgoBalanceA).toEqual(adminAlgoBalanceB + oldTotalUnclaimedFees + additionalRewardsFee);
+    //   expect(algoBalance).toEqual(oldAlgoBalance);
+    //   expect(xAlgoCirculatingSupply).toEqual(oldXAlgoCirculatingSupply);
+    //   expect(proposersBalances[0] + proposersBalances[1]).toEqual(oldProposersBalance[0] + oldProposersBalance[1] - (oldTotalUnclaimedFees + additionalRewardsFee));
+    //   expect(proposersBalances[0] - BigInt(1)).toBeLessThanOrEqual(proposersBalances[1]);
+    //   expect(proposersBalances[0] + BigInt(1)).toBeGreaterThanOrEqual(proposersBalances[1]);
+    //   expect(transfer.type).toEqual("pay");
+    //   expect(transfer.amt).toEqual(Number(oldTotalUnclaimedFees + additionalRewardsFee));
+    //   expect(transfer.snd).toEqual(decodeAddress(getApplicationAddress(xAlgoAppId)).publicKey);
+    //   expect(transfer.rcv).toEqual(decodeAddress(admin.addr).publicKey);
+    // });    
     test("succeeds", async () => {
       // airdrop 10 ALGO rewards (%fee of which will be claimable by admin)
       const additionalRewards = BigInt(10e6);
@@ -1537,11 +1576,7 @@ describe("Algo Consensus V2", () => {
 
       // balances before
       const adminAlgoBalanceB = await getAlgoBalance(algodClient, admin.addr);
-      const { algoBalance: oldAlgoBalance, xAlgoCirculatingSupply: oldXAlgoCirculatingSupply, proposersBalances: oldProposersBalance } = await getXAlgoRate();
 
-      // state before
-      let state = await parseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);
-      const { totalRewards: oldTotalRewards, totalUnclaimedFees: oldTotalUnclaimedFees } = state;
 
       // claim fee
       const proposerAddrs = [proposer0.addr, proposer1.addr];
@@ -1549,24 +1584,21 @@ describe("Algo Consensus V2", () => {
       const txId = await submitTransaction(algodClient, tx, user1.sk);
       const txInfo = await algodClient.pendingTransactionInformation(txId).do();
       const { txn: transfer } = txInfo['inner-txns'][txInfo['inner-txns'].length - 1].txn;
-      state = await parseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);
-      expect(state.totalRewards).toEqual(oldTotalRewards + additionalRewards - (oldTotalUnclaimedFees + additionalRewardsFee));
-      expect(state.totalUnclaimedFees).toEqual(BigInt(0));
 
+      let state = await parseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);
+      const { totalRewards, totalUnclaimedFees,totalActiveStake, totalPendingStake } = state;
+      console.log("totoal pending stake", totalPendingStake);
+      console.log("total active stake", totalActiveStake);
+      console.log("total unclaimed fees", totalUnclaimedFees);
+      console.log(totalPendingStake, totalActiveStake, totalRewards, totalUnclaimedFees);
       // balances after
-      const adminAlgoBalanceA = await getAlgoBalance(algodClient, admin.addr);
-      const { algoBalance, xAlgoCirculatingSupply, proposersBalances } = await getXAlgoRate();
-      expect(adminAlgoBalanceA).toEqual(adminAlgoBalanceB + oldTotalUnclaimedFees + additionalRewardsFee);
-      expect(algoBalance).toEqual(oldAlgoBalance);
-      expect(xAlgoCirculatingSupply).toEqual(oldXAlgoCirculatingSupply);
-      expect(proposersBalances[0] + proposersBalances[1]).toEqual(oldProposersBalance[0] + oldProposersBalance[1] - (oldTotalUnclaimedFees + additionalRewardsFee));
-      expect(proposersBalances[0] - BigInt(1)).toBeLessThanOrEqual(proposersBalances[1]);
-      expect(proposersBalances[0] + BigInt(1)).toBeGreaterThanOrEqual(proposersBalances[1]);
-      expect(transfer.type).toEqual("pay");
-      expect(transfer.amt).toEqual(Number(oldTotalUnclaimedFees + additionalRewardsFee));
-      expect(transfer.snd).toEqual(decodeAddress(getApplicationAddress(xAlgoAppId)).publicKey);
-      expect(transfer.rcv).toEqual(decodeAddress(admin.addr).publicKey);
+      
+      const { algoBalance } = await getXAlgoRate();
+      console.log("proposers algo balance", algoBalance );
+
+      console.log("unclaimable balance", algoBalance - totalActiveStake)
     });
+    
   });
 
   describe("update smart contract", () => {