test/BurrowMarket.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BurrowMarket} from "../src/BurrowMarket.sol";
import {BurrowVaultLite} from "../src/BurrowVaultLite.sol";
import {
BurrowVaultFixture,
BurrowTestBase,
BurrowTestNFT,
BurrowTestToken,
BurrowSepoliaForkFixture,
IERC20,
IERC721,
ERC721Holder
} from "./Fixtures.t.sol";
contract BurrowTestFeeSink {
BurrowTestToken public immutable token;
bool public fail;
bool public ignore;
uint256 public donated;
error DonationRejected();
constructor(BurrowTestToken t) {
token = t;
}
function setFail(bool b) external {
fail = b;
}
function setIgnore(bool b) external {
ignore = b;
}
function donate(uint256 amount) external {
if (fail) revert DonationRejected();
if (ignore) return;
token.transferFrom(msg.sender, address(this), amount);
donated += amount;
}
}
contract BurrowMarketReentrantBuyer is ERC721Holder {
BurrowMarket public immutable market;
BurrowTestToken public immutable token;
bool public callbackSucceeded;
bytes public callbackResult;
constructor(BurrowMarket m, BurrowTestToken t) {
market = m;
token = t;
}
function buy(uint256 id) external {
token.approve(address(market), type(uint256).max);
market.buy(id, type(uint256).max);
}
function onERC721Received(address, address, uint256 id, bytes memory) public override returns (bytes4) {
(callbackSucceeded, callbackResult) = address(market).call(abi.encodeWithSignature("buy(uint256,uint256)", id, type(uint256).max));
return this.onERC721Received.selector;
}
}
contract BurrowMarketRejectingBuyer {
function buy(BurrowMarket market, BurrowTestToken token, uint256 id) external {
token.approve(address(market), type(uint256).max);
market.buy(id, type(uint256).max);
}
}
contract BurrowMarketTest is BurrowVaultFixture {
BurrowMarket internal market;
event Listed(uint256 indexed tokenId, address indexed seller, uint256 price, uint256 feeBps);
event Sold(uint256 indexed tokenId, address indexed seller, address indexed buyer, uint256 price, uint256 fee);
function setUp() public {
_setup(0);
_seed(10000e18);
market = new BurrowMarket(address(token), address(nft), address(vault), address(this), 250, 1 days);
vm.prank(alice);
nft.setApprovalForAll(address(market), true);
vm.prank(bob);
token.approve(address(market), type(uint256).max);
nft.mint(alice, 1, 1);
nft.mint(alice, 2, 2);
}
function _list(uint256 id, uint256 price) internal {
vm.prank(alice);
market.list(id, price);
}
function test_FixedSalePaysSellerAndRaisesVaultSharePriceAtomically() public {
uint256 backing = vault.totalAssets();
uint256 supply = vault.totalSupply();
uint256 seller = token.balanceOf(alice);
uint256 buyer = token.balanceOf(bob);
vm.expectEmit(true, true, false, true, address(market));
emit Listed(1, alice, 100e18, 250);
_list(1, 100e18);
assertEq(nft.ownerOf(1), address(market), "listing escrow");
vm.expectEmit(true, true, true, true, address(market));
emit Sold(1, alice, bob, 100e18, 2.5e18);
vm.prank(bob);
market.buy(1, type(uint256).max);
assertEq(token.balanceOf(alice), seller + 97.5e18, "seller net same transaction");
assertEq(token.balanceOf(bob), buyer - 100e18, "buyer gross");
assertEq(vault.totalAssets(), backing + 2.5e18, "fee reaches actual vault");
assertEq(vault.totalSupply(), supply, "fee mints no shares");
assertTrue(vault.convertToAssets(1e18) > 1e18, "all lenders benefit");
assertEq(nft.ownerOf(1), bob, "buyer custody");
assertEq(token.balanceOf(address(market)), 0, "no SQK stranded");
assertEq(token.allowance(address(market), address(vault)), 0, "fee allowance revoked");
(address sellerAfter,,,,,) = market.listings(1);
assertEq(sellerAfter, address(0), "listing consumed");
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotListed.selector, 1));
vm.prank(bob);
market.buy(1, type(uint256).max);
}
function test_CancelOnlyBySellerRestoresNFTAndCanRelist() public {
_list(1, 100e18);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotSeller.selector, 1));
vm.prank(bob);
market.cancel(1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotSeller.selector, 1));
market.cancel(1);
vm.prank(alice);
market.cancel(1);
assertEq(nft.ownerOf(1), alice, "seller custody restored");
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotListed.selector, 1));
vm.prank(alice);
market.cancel(1);
_list(1, 200e18);
assertEq(market.currentPrice(1), 200e18, "relisted price");
}
function test_FeeChangesCannotRewriteExistingSellerTerms() public {
_list(1, 100e18);
market.setFeeBps(500);
_list(2, 100e18);
uint256 backing = vault.totalAssets();
vm.prank(bob);
market.buy(1, type(uint256).max);
assertEq(vault.totalAssets(), backing + 2.5e18, "old fee preserved");
vm.prank(bob);
market.buy(2, type(uint256).max);
assertEq(vault.totalAssets(), backing + 7.5e18, "new listing fee applied");
}
function test_DutchAuctionStartMidpointLastSecondAndFloor() public {
vm.prank(alice);
market.startAuction(1, 200e18, 100e18);
uint256 start = block.timestamp;
assertEq(market.currentPrice(1), 200e18, "premium at start");
vm.warp(start + 12 hours);
assertEq(market.currentPrice(1), 150e18, "linear midpoint");
vm.warp(start + 1 days - 1);
assertTrue(market.currentPrice(1) > 100e18, "above floor before expiry");
vm.warp(start + 1 days);
assertEq(market.currentPrice(1), 100e18, "floor at expiry");
vm.warp(start + 30 days);
assertEq(market.currentPrice(1), 100e18, "floor persists");
vm.prank(bob);
market.buy(1, 100e18);
assertEq(nft.ownerOf(1), bob, "expired auction remains buyable");
}
/// forge-config: default.fuzz.runs = 1000
function testFuzz_DutchPriceMonotoneAndSettlementConservesSQK(
uint128 premium,
uint128 floorRaw,
uint32 t,
uint16 feeRaw
) public {
uint256 floor = bound(floorRaw, 1, 1e25);
uint256 start = floor + bound(premium, 1, 1e25);
uint256 fee = bound(feeRaw, 0, 500);
market.setFeeBps(fee);
vm.prank(alice);
market.startAuction(1, start, floor);
uint256 elapsed = bound(t, 0, 2 days);
vm.warp(block.timestamp + elapsed);
uint256 price = elapsed >= 1 days ? floor : start - (start - floor) * elapsed / 1 days;
assertEq(market.currentPrice(1), price, "linear auction quote");
assertGe(price, floor, "floor");
assertLe(price, start, "premium");
uint256 a = token.balanceOf(alice);
uint256 b = token.balanceOf(bob);
uint256 backing = vault.totalAssets();
vm.prank(bob);
market.buy(1, price);
uint256 charge = price * fee / 10000;
assertEq(token.balanceOf(alice) - a, price - charge, "seller receives price less fee");
assertEq(b - token.balanceOf(bob), price, "buyer pays quote");
assertEq(vault.totalAssets() - backing, charge, "fee exact");
assertEq(token.balanceOf(address(market)), 0, "no residue");
}
function test_DutchPricingHandlesMaximumUintWithoutOverflow() public {
vm.prank(alice);
market.startAuction(1, type(uint256).max, 1);
vm.warp(block.timestamp + 12 hours);
assertEq(market.currentPrice(1), type(uint256).max - (type(uint256).max - 1) / 2, "full precision price");
}
function test_SlippageRejectsRelistedPriceWithoutMovingValue() public {
_list(1, 100e18);
vm.prank(alice);
market.cancel(1);
_list(1, 200e18);
uint256 b = token.balanceOf(bob);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.PriceExceedsLimit.selector, 200e18, 100e18));
vm.prank(bob);
market.buy(1, 100e18);
assertEq(token.balanceOf(bob), b, "slippage charged buyer");
assertEq(nft.ownerOf(1), address(market), "failed purchase moved NFT");
}
function test_ListingAuctionAndMissingListingErrors() public {
vm.expectRevert(BurrowMarket.InvalidPrice.selector);
vm.prank(alice);
market.list(1, 0);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotTokenOwner.selector, 1));
vm.prank(bob);
market.list(1, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.InvalidAuctionPrices.selector, 1, 0));
vm.prank(alice);
market.startAuction(1, 1, 0);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.InvalidAuctionPrices.selector, 1, 1));
vm.prank(alice);
market.startAuction(1, 1, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.InvalidAuctionPrices.selector, 1, 2));
vm.prank(alice);
market.startAuction(1, 1, 2);
_list(1, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.AlreadyListed.selector, 1));
vm.prank(alice);
market.list(1, 2);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.AlreadyListed.selector, 1));
vm.prank(alice);
market.startAuction(1, 2, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotListed.selector, 2));
market.currentPrice(2);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotListed.selector, 2));
market.buy(2, type(uint256).max);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.NotListed.selector, 2));
market.cancel(2);
}
function test_ApprovalAndPaymentFailuresPreserveListing() public {
vm.prank(alice);
nft.setApprovalForAll(address(market), false);
vm.expectRevert(BurrowTestNFT.Unauthorized.selector);
vm.prank(alice);
market.list(1, 100e18);
(address seller,,,,,) = market.listings(1);
assertEq(seller, address(0), "failed escrow left listing");
vm.prank(alice);
nft.setApprovalForAll(address(market), true);
_list(1, 100e18);
vm.prank(bob);
token.approve(address(market), 0);
vm.expectRevert(
abi.encodeWithSignature("ERC20InsufficientAllowance(address,uint256,uint256)", address(market), 0, 100e18)
);
vm.prank(bob);
market.buy(1, type(uint256).max);
assertEq(nft.ownerOf(1), address(market), "payment failure released NFT");
assertEq(market.currentPrice(1), 100e18, "payment failure consumed listing");
}
function test_UnsolicitedNFTAndForgedCallbackRejected() public {
vm.expectRevert(BurrowMarket.UnexpectedNFT.selector);
vm.prank(alice);
nft.safeTransferFrom(alice, address(market), 1);
vm.expectRevert(BurrowMarket.UnexpectedNFT.selector);
market.onERC721Received(address(market), alice, 1, "");
vm.expectRevert(BurrowMarket.UnexpectedNFT.selector);
vm.prank(address(nft));
market.onERC721Received(address(market), alice, 1, "");
}
function test_TaxedSQKAndNonCollectingSinkRollbackEntireSale() public {
_list(1, 100e18);
token.setTax(true);
uint256 b = token.balanceOf(bob);
vm.expectRevert(BurrowMarket.UnexpectedSQKTransfer.selector);
vm.prank(bob);
market.buy(1, type(uint256).max);
assertEq(token.balanceOf(bob), b, "taxed payment charged");
assertEq(market.currentPrice(1), 100e18, "taxed payment consumed listing");
token.setTax(false);
BurrowTestFeeSink bad = new BurrowTestFeeSink(token);
bad.setIgnore(true);
BurrowMarket m = new BurrowMarket(address(token), address(nft), address(bad), address(this), 500, 1 days);
vm.startPrank(alice);
nft.setApprovalForAll(address(m), true);
m.list(2, 100e18);
vm.stopPrank();
vm.prank(bob);
token.approve(address(m), type(uint256).max);
vm.expectRevert(BurrowMarket.FeeNotCollected.selector);
vm.prank(bob);
m.buy(2, type(uint256).max);
assertEq(nft.ownerOf(2), address(m), "noncollecting sink released NFT");
assertEq(token.balanceOf(bob), b, "sink failure charged buyer");
bad.setIgnore(false);
bad.setFail(true);
vm.expectRevert(BurrowTestFeeSink.DonationRejected.selector);
vm.prank(bob);
m.buy(2, type(uint256).max);
assertEq(m.currentPrice(2), 100e18, "rejected donation consumed listing");
}
function test_NFTReceiverCannotReenterSaleAndRejectedReceiverRollsBack() public {
_list(1, 100e18);
BurrowMarketReentrantBuyer attacker = new BurrowMarketReentrantBuyer(market, token);
token.mint(address(attacker), 100e18);
attacker.buy(1);
assertTrue(!attacker.callbackSucceeded(), "receiver reentered sale");
assertEq(
keccak256(attacker.callbackResult()),
keccak256(abi.encodeWithSignature("ReentrancyGuardReentrantCall()")),
"guard error"
);
assertEq(nft.ownerOf(1), address(attacker), "legitimate outer sale completes");
_list(2, 100e18);
BurrowMarketRejectingBuyer rejector = new BurrowMarketRejectingBuyer();
token.mint(address(rejector), 100e18);
uint256 backing = vault.totalAssets();
vm.expectRevert();
rejector.buy(market, token, 2);
assertEq(token.balanceOf(address(rejector)), 100e18, "rejected recipient charged");
assertEq(vault.totalAssets(), backing, "reverted sale leaked fees");
assertEq(nft.ownerOf(2), address(market), "NFT lost");
}
function test_ZeroFeeAndSubWeiFeeDoNotCallDonateZero() public {
market.setFeeBps(0);
_list(1, 100e18);
uint256 backing = vault.totalAssets();
vm.prank(bob);
market.buy(1, type(uint256).max);
assertEq(vault.totalAssets(), backing, "zero fee");
market.setFeeBps(500);
_list(2, 1);
vm.prank(bob);
market.buy(2, type(uint256).max);
assertEq(vault.totalAssets(), backing, "rounded zero fee");
}
function test_ConstructorValidationAndOwnerFeeCap() public {
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.InvalidContract.selector, alice));
new BurrowMarket(alice, address(nft), address(vault), address(this), 0, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.InvalidContract.selector, alice));
new BurrowMarket(address(token), alice, address(vault), address(this), 0, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.InvalidContract.selector, alice));
new BurrowMarket(address(token), address(nft), alice, address(this), 0, 1);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.FeeTooHigh.selector, 501));
new BurrowMarket(address(token), address(nft), address(vault), address(this), 501, 1);
vm.expectRevert(BurrowMarket.InvalidAuctionDuration.selector);
new BurrowMarket(address(token), address(nft), address(vault), address(this), 0, 0);
vm.expectRevert(abi.encodeWithSelector(BurrowMarket.FeeTooHigh.selector, 501));
market.setFeeBps(501);
vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", bob));
vm.prank(bob);
market.setFeeBps(1);
market.setFeeBps(500);
assertEq(market.feeBps(), 500, "inclusive cap");
}
function test_LiquidatorCanAuctionCollateralAndRouteSaleFee() public {
_open(alice, 10, 1);
_borrow(alice, 600e18);
// Liquidation opens at 75% of value: two governed 20% cuts take value from 1000 to 640.
valuer.proposePricePerPower(0.8e18);
vm.warp(block.timestamp + 2 days);
valuer.executePricePerPower();
valuer.proposePricePerPower(0.64e18);
vm.warp(block.timestamp + 2 days);
valuer.executePricePerPower();
vm.warp(block.timestamp + 1 hours);
vm.prank(liquidator);
vault.liquidate(alice);
vm.startPrank(liquidator);
nft.setApprovalForAll(address(market), true);
market.startAuction(10, 1000e18, 800e18);
vm.stopPrank();
vm.warp(block.timestamp + 12 hours);
uint256 assets = vault.totalAssets();
vm.prank(bob);
market.buy(10, 900e18);
assertEq(nft.ownerOf(10), bob, "liquidated NFT sold");
assertEq(vault.totalAssets(), assets + 22.5e18, "liquidation auction fee");
}
}
contract BurrowMarketHandler is BurrowTestBase {
BurrowMarket public immutable market;
BurrowTestNFT public immutable nft;
BurrowTestToken public immutable token;
BurrowVaultLite public immutable vault;
address[3] public actors;
address[9] public modelOwner;
address[9] public seller;
uint256[9] public startPrice;
uint256[9] public floorPrice;
uint256[9] public listedAt;
uint256[9] public listingFee;
uint256 public expectedFees;
bool public badSettlement;
constructor(BurrowMarket m, BurrowTestNFT n, BurrowTestToken t, BurrowVaultLite v) {
market = m;
nft = n;
token = t;
vault = v;
for (uint256 i; i < 3; i++) {
address who = address(uint160(uint256(keccak256(abi.encode("market actor", i)))));
actors[i] = who;
t.mint(who, 1e27);
vm.startPrank(who);
n.setApprovalForAll(address(m), true);
t.approve(address(m), type(uint256).max);
vm.stopPrank();
}
for (uint256 id = 1; id <= 8; id++) {
address who = actors[id % 3];
n.mint(who, id, 1);
modelOwner[id] = who;
}
}
function acceptOwnership() external {
market.acceptOwnership();
}
function list(uint256 rawId, uint256 rawPrice, bool auction) external {
uint256 id = bound(rawId, 1, 8);
if (seller[id] != address(0)) return;
uint256 price = bound(rawPrice, 2, 1e22);
address who = modelOwner[id];
seller[id] = who;
startPrice[id] = price;
floorPrice[id] = auction ? price / 2 : price;
listedAt[id] = block.timestamp;
listingFee[id] = market.feeBps();
vm.prank(who);
if (auction) market.startAuction(id, price, price / 2);
else market.list(id, price);
}
function cancel(uint256 rawId) external {
uint256 id = bound(rawId, 1, 8);
address who = seller[id];
if (who == address(0)) return;
seller[id] = address(0);
vm.prank(who);
market.cancel(id);
}
function buy(uint256 rawId, uint256 buyerIndex) external {
uint256 id = bound(rawId, 1, 8);
address who = seller[id];
if (who == address(0)) return;
address buyer = actors[buyerIndex % 3];
if (buyer == who) buyer = actors[(buyerIndex % 3 + 1) % 3];
uint256 elapsed = block.timestamp - listedAt[id];
uint256 price =
elapsed >= 1 days ? floorPrice[id] : startPrice[id] - (startPrice[id] - floorPrice[id]) * elapsed / 1 days;
uint256 fee = price * listingFee[id] / 10000;
uint256 oldSeller = token.balanceOf(who);
uint256 oldBuyer = token.balanceOf(buyer);
uint256 oldVault = vault.totalAssets();
seller[id] = address(0);
modelOwner[id] = buyer;
expectedFees += fee;
vm.prank(buyer);
market.buy(id, price);
if (
token.balanceOf(who) != oldSeller + price - fee || token.balanceOf(buyer) != oldBuyer - price
|| vault.totalAssets() != oldVault + fee
) badSettlement = true;
}
function advance(uint32 raw) external {
vm.warp(block.timestamp + bound(raw, 1, 2 days));
}
function setFee(uint16 raw) external {
market.setFeeBps(bound(raw, 0, 500));
}
}
contract BurrowMarketInvariantTest is BurrowVaultFixture {
BurrowMarket internal market;
BurrowMarketHandler internal handler;
function setUp() public {
_setup(0);
_seed(1e24);
market = new BurrowMarket(address(token), address(nft), address(vault), address(this), 250, 1 days);
handler = new BurrowMarketHandler(market, nft, token, vault);
market.transferOwnership(address(handler));
handler.acceptOwnership();
}
function targetContracts() public view returns (address[] memory a) {
a = new address[](1);
a[0] = address(handler);
}
struct FuzzSelector {
address addr;
bytes4[] selectors;
}
function targetSelectors() public view returns (FuzzSelector[] memory s) {
s = new FuzzSelector[](1);
bytes4[] memory a = new bytes4[](5);
a[0] = handler.list.selector;
a[1] = handler.cancel.selector;
a[2] = handler.buy.selector;
a[3] = handler.advance.selector;
a[4] = handler.setFee.selector;
s[0] = FuzzSelector(address(handler), a);
}
/// forge-config: default.invariant.runs = 256
/// forge-config: default.invariant.depth = 64
/// forge-config: default.invariant.fail-on-revert = true
function invariant_EscrowOwnershipAndSaleAccounting() public view {
assertTrue(!handler.badSettlement(), "sale did not conserve price and fee");
assertEq(token.balanceOf(address(market)), 0, "market retained SQK");
assertEq(token.allowance(address(market), address(vault)), 0, "residual allowance");
assertEq(vault.totalAssets(), 1e24 + handler.expectedFees(), "fee income reconciliation");
uint256 held;
for (uint256 i; i < 3; i++) {
held += token.balanceOf(handler.actors(i));
}
assertEq(held + handler.expectedFees(), 3e27, "all SQK conserved between buyers sellers and vault");
for (uint256 id = 1; id <= 8; id++) {
address expectedSeller = handler.seller(id);
(address actualSeller,,,,,) = market.listings(id);
assertEq(actualSeller, expectedSeller, "listing model");
assertEq(
nft.ownerOf(id),
expectedSeller == address(0) ? handler.modelOwner(id) : address(market),
"NFT escrow model"
);
}
}
}
contract BurrowMarketSepoliaForkTest is BurrowSepoliaForkFixture {
function testFork_SepoliaRealNFTSalePaysRealSQKToVault() public {
_fork();
(BurrowVaultLite v,) = _liveVault();
BurrowMarket m = new BurrowMarket(SQK, NFT, address(v), address(this), 250, 1 days);
address holder = IERC721(NFT).ownerOf(2);
address buyer = addr("fork market buyer");
address lender = addr("fork market lender");
deal(SQK, lender, 10000e18);
vm.startPrank(lender);
IERC20(SQK).approve(address(v), type(uint256).max);
v.deposit(10000e18, 0);
vm.stopPrank();
vm.startPrank(holder);
IERC721(NFT).approve(address(m), 2);
m.list(2, 100e18);
vm.stopPrank();
deal(SQK, buyer, 100e18);
uint256 sellerBalance = IERC20(SQK).balanceOf(holder);
vm.startPrank(buyer);
IERC20(SQK).approve(address(m), 100e18);
m.buy(2, 100e18);
vm.stopPrank();
assertEq(IERC721(NFT).ownerOf(2), buyer, "real NFT delivered");
assertEq(IERC20(SQK).balanceOf(holder), sellerBalance + 97.5e18, "real seller paid");
assertEq(v.totalAssets(), 10002.5e18, "real SQK fee reached vault");
assertTrue(v.convertToAssets(1e18) > 1e18, "live fee raised share price");
}
}