Summary
The BuyerAgent contract allows withdrawals during the Buy phase as long as the minimum fund amount is maintained. This can lead to a situation where the contract has insufficient funds to execute purchases, potentially breaking its core functionality.
Vulnerability Details
The vulnerability exists in the withdraw function of the BuyerAgent contract:
https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/BuyerAgent.sol#L267-L272
function withdraw(uint96 _amount) public onlyAuthorized {
(, Phase phase,) = getRoundPhase();
if (phase != Phase.Withdraw) {
if (treasury() < minFundAmount() + _amount) {
revert MinFundSubceeded(_amount);
}
}
swan.token().transfer(owner(), _amount);
}
The function only checks that minFundAmount remains in the contract after withdrawal, without considering whether the remaining funds are sufficient for pending or future purchases during the Buy phase.
Impact
This issue allows authorized users to withdraw funds during the Buy phase, potentially leaving the contract with insufficient funds to execute purchases. This breaks the core functionality of the BuyerAgent contract.
POC Test:
describe("POC: Withdraw during Buy phase can break purchase", function () {
const ASSET_PRICE = parseEther("0.05");
beforeEach(async function () {
token = await deployTokenFixture(dria, SUPPLY);
MARKET_PARAMETERS.timestamp = (await ethers.provider
.getBlock("latest")
.then((block) => block?.timestamp)) as bigint;
({ swan } = await deploySwanFixture(dria, token, STAKES, FEES, MARKET_PARAMETERS, ORACLE_PARAMETERS));
await transferTokens(token, [[buyerAgentOwner.address, parseEther("1")]]);
buyerAgent = await loadFixture(
buyerAgentDeployer(swan, {
name: "Name of the agent",
description: "Description of the agent",
royaltyFee: ROYALTY_FEE,
amountPerRound: AMOUNT_PER_ROUND,
owner: buyerAgentOwner,
})
);
const minFundAmount = await buyerAgent.minFundAmount();
const requiredAmount = ASSET_PRICE + minFundAmount;
await token.connect(buyerAgentOwner).transfer(await buyerAgent.getAddress(), requiredAmount);
});
it("should demonstrate the vulnerability in withdraw during Buy phase", async function () {
await time.increase(MARKET_PARAMETERS.sellInterval);
const [, phase] = await buyerAgent.getRoundPhase();
expect(phase).to.equal(Phase.Buy);
const initialBalance = await buyerAgent.treasury();
const minFundAmount = await buyerAgent.minFundAmount();
const withdrawAmount = initialBalance - minFundAmount;
await buyerAgent.connect(buyerAgentOwner).withdraw(withdrawAmount);
expect(await buyerAgent.treasury()).to.equal(minFundAmount);
expect(await buyerAgent.treasury()).to.be.lessThan(ASSET_PRICE + minFundAmount);
});
});
Test Result:
BuyerAgent
POC: Withdraw during Buy phase can break purchase
✔ should demonstrate the vulnerability in withdraw during Buy phase
Tools Used
Manual code review
Hardhat testing framework
Typescript/Solidity
Recommendations
Restrict withdrawals to only the Withdraw phase:
function withdraw(uint96 _amount) public onlyAuthorized {
(, Phase phase,) = getRoundPhase();
if (phase != Phase.Withdraw) {
revert InvalidPhase(phase, Phase.Withdraw);
}
swan.token().transfer(owner(), _amount);
}
Alternative: Implement a lock mechanism during the Buy phase when a purchase request is pending, ensuring sufficient funds remain for the purchase.
Alternative: Add a check to ensure that sufficient funds remain for at least one purchase during the Buy phase:
if (phase == Phase.Buy && treasury() - _amount < minFundAmount() + amountPerRound) {
revert InsufficientFundsForPurchase();
}