Skip to content

Commit

Permalink
Add early participation window for emissaries in SealedBidTokenSale c…
Browse files Browse the repository at this point in the history
…ontract with tests for deposit logic and boundary conditions.
  • Loading branch information
ylv-io committed Feb 6, 2025
1 parent e69f4d2 commit afe8f82
Show file tree
Hide file tree
Showing 2 changed files with 227 additions and 15 deletions.
65 changes: 54 additions & 11 deletions src/apps/SealedBidTokenSale.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,41 @@ import {SafeERC20} from "@openzeppelin-5.0.1/contracts/token/ERC20/utils/SafeERC
* - USDC deposits from users
* - Merkle-based token allocation claims
* - Full refunds if minimum cap not reached
* - Early participation window for first 700 emissaries
*/
contract SealedBidTokenSale is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;

/* ============ Struct ============ */

struct SaleInfo {
/// @notice Timestamp when emissary early access begins
uint256 preStartTime;
/// @notice Timestamp when public sale begins
uint256 startTime;
/// @notice Minimum USDC required for sale success
uint256 minimumCap;
/// @notice Total USDC deposited by all users
uint256 totalDeposited;
/// @notice Total USDC withdrawn after failed sale
uint256 totalWithdrawn;
/// @notice Total USDC claimed by users
uint256 totalUsdcClaimed;
/// @notice Total sale tokens claimed
uint256 totalSaleTokenClaimed;
/// @notice Whether sale has been officially ended
bool saleEnded;
/// @notice Whether minimum cap was reached
bool capReached;
/// @notice Whether specified user has claimed tokens
bool hasClaimed;
/// @notice Total number of unique depositors
uint256 contributorCount;
/// @notice Current number of emissary participants
uint256 currentEmissaryCount;
/// @notice Deposit amount for specified user
uint256 depositAmount;
/// @notice Max price set by specified user
uint256 maxPrice;
}

Expand Down Expand Up @@ -65,6 +82,10 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
error MinDeposit(uint256 amount);
/// @notice Thrown when new max price is out of range
error MaxPriceOutOfRange(uint256 amount);
/// @notice Thrown when emissary slots are fully occupied
error EmissaryFull();
/// @notice Thrown when time configuration is invalid
error InvalidTimeConfiguration();

/* ============ Events ============ */

Expand Down Expand Up @@ -103,6 +124,8 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {

/// @notice Token being sold in the sale
uint256 public constant MIN_DEPOSIT = 250 * 1e6;
/// @notice Maximum number of emissaries
uint256 public constant MAX_EMISSARIES = 700;

/* ============ Immutable ============ */

Expand All @@ -112,6 +135,8 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
IERC20 public immutable USDC;
/// @notice Address where sale proceeds will be sent
address public immutable treasury;
/// @notice Timestamp when emissary early access begins
uint256 public immutable preStartTime;
/// @notice Timestamp when the sale begins
uint256 public immutable startTime;
/// @notice Minimum amount of USDC required for sale success
Expand Down Expand Up @@ -141,6 +166,10 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
mapping(address => uint256) public maxPrices;
/// @notice Count of all contributors
uint256 public contributorCount;
/// @notice Current number of emissary participants
uint256 public currentEmissaryCount;
/// @notice Maps user addresses to emissary status
mapping(address => bool) public isEmissary;

/* ============ Constructor ============ */

Expand All @@ -152,15 +181,22 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
* @param _startTime Timestamp when sale will begin
* @param _minimumCap Minimum USDC amount required for sale success
*/
constructor(address _saleToken, address _treasury, address _usdcToken, uint256 _startTime, uint256 _minimumCap)
Ownable(msg.sender)
{
constructor(
address _saleToken,
address _treasury,
address _usdcToken,
uint256 _preStartTime,
uint256 _startTime,
uint256 _minimumCap
) Ownable(msg.sender) {
if (_saleToken == address(0)) revert InvalidSaleTokenAddress(_saleToken);
if (_treasury == address(0)) revert InvalidTreasuryAddress(_treasury);
if (_preStartTime >= _startTime) revert InvalidTimeConfiguration();

saleToken = IERC20(_saleToken);
treasury = _treasury;
USDC = IERC20(_usdcToken);
preStartTime = _preStartTime;
startTime = _startTime;
minimumCap = _minimumCap;
}
Expand All @@ -177,13 +213,20 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
* @param maxPrice The maximum price set by the user for the token sale
*/
function deposit(uint256 amount, uint256 maxPrice) external nonReentrant {
// Verify sale is active and deposit is valid
if (block.timestamp < startTime) revert SaleNotStarted(block.timestamp, startTime);
if (saleEnded) revert SaleAlreadyEnded(block.timestamp);
if (block.timestamp < preStartTime) revert SaleNotStarted(block.timestamp, preStartTime);
if (amount < MIN_DEPOSIT) revert MinDeposit(amount);
_checkMaxPrice(maxPrice);

// Update deposit accounting
// Handle emissary period
if (block.timestamp < startTime) {
if (currentEmissaryCount >= MAX_EMISSARIES) revert EmissaryFull();
if (!isEmissary[msg.sender]) {
isEmissary[msg.sender] = true;
currentEmissaryCount++;
}
}

deposits[msg.sender] += amount;
totalDeposited += amount;
contributorCount++;
Expand Down Expand Up @@ -273,7 +316,7 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
* @param newMaxPrice The new maximum price value to be set for the user.
*/
function updateMaxPrice(uint256 newMaxPrice) external nonReentrant {
if (block.timestamp < startTime) revert SaleNotStarted(block.timestamp, startTime);
if (block.timestamp < preStartTime) revert SaleNotStarted(block.timestamp, preStartTime);
if (saleEnded) revert SaleAlreadyEnded(block.timestamp);
_checkMaxPrice(newMaxPrice);

Expand All @@ -283,9 +326,7 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
}

function _checkMaxPrice(uint256 newMaxPrice) internal pure {
if (newMaxPrice < 10 * 1e6 || newMaxPrice > 30 * 1e6) {
revert MaxPriceOutOfRange(newMaxPrice);
}
if (newMaxPrice < 10 * 1e6 || newMaxPrice > 30 * 1e6) revert MaxPriceOutOfRange(newMaxPrice);
}

/* ============ Admin Functions ============ */
Expand Down Expand Up @@ -342,6 +383,7 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {

function saleStatus(address user) external view returns (SaleInfo memory) {
return SaleInfo({
preStartTime: preStartTime,
startTime: startTime,
minimumCap: minimumCap,
totalDeposited: totalDeposited,
Expand All @@ -351,8 +393,9 @@ contract SealedBidTokenSale is Ownable, ReentrancyGuard {
saleEnded: saleEnded,
capReached: capReached,
hasClaimed: hasClaimed[user],
depositAmount: deposits[user],
contributorCount: contributorCount,
currentEmissaryCount: currentEmissaryCount,
depositAmount: deposits[user],
maxPrice: maxPrices[user]
});
}
Expand Down
177 changes: 173 additions & 4 deletions test/unit/apps/SealedBidTokenSale.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ contract SealedBidTokenSaleTest is SharedSetup {
ERC20Mock public usdc;
ERC20Mock public saleToken;

uint256 public preStartTime;
uint256 public startTime;
uint256 public endTime;
uint256 public constant MIN_CAP = 10e6 * 1e6;
Expand All @@ -29,7 +30,8 @@ contract SealedBidTokenSaleTest is SharedSetup {
function setUp() public override {
super.setUp();

startTime = block.timestamp + 1 days;
preStartTime = block.timestamp + 1 days;
startTime = block.timestamp + 2 days;
endTime = startTime + 4 days;

// Deploy mock tokens
Expand All @@ -38,7 +40,7 @@ contract SealedBidTokenSaleTest is SharedSetup {

// Deploy sale contract with admin as owner
vm.prank(admin);
sale = new SealedBidTokenSale(address(saleToken), TREASURY, address(usdc), startTime, MIN_CAP);
sale = new SealedBidTokenSale(address(saleToken), TREASURY, address(usdc), preStartTime, startTime, MIN_CAP);

// Setup Merkle tree with alice and bob
bytes32[] memory leaves = new bytes32[](2);
Expand Down Expand Up @@ -155,7 +157,9 @@ contract SealedBidTokenSaleTest is SharedSetup {
}

function testDeposit_RevertWhen_BeforeStart() public {
vm.expectRevert(abi.encodeWithSelector(SealedBidTokenSale.SaleNotStarted.selector, block.timestamp, startTime));
vm.expectRevert(
abi.encodeWithSelector(SealedBidTokenSale.SaleNotStarted.selector, block.timestamp, preStartTime)
);
vm.prank(alice);
sale.deposit(100 ether, maxPrice);
}
Expand Down Expand Up @@ -1047,7 +1051,9 @@ contract SealedBidTokenSaleTest is SharedSetup {

function testUpdateMaxPrice_Timing() public {
// Should fail before sale starts
vm.expectRevert(abi.encodeWithSelector(SealedBidTokenSale.SaleNotStarted.selector, block.timestamp, startTime));
vm.expectRevert(
abi.encodeWithSelector(SealedBidTokenSale.SaleNotStarted.selector, block.timestamp, preStartTime)
);
vm.prank(alice);
sale.updateMaxPrice(1e6);

Expand Down Expand Up @@ -1293,4 +1299,167 @@ contract SealedBidTokenSaleTest is SharedSetup {
assertEq(aliceInfo.contributorCount, 2, "Contributor count should be 2");
assertEq(bobInfo.contributorCount, 2, "Contributor count should be same for all users");
}

/* ============ saleStatus ============ */

function testEmissaryDeposit_During_EarlyAccess() public {
// Set time to early access period
vm.warp(preStartTime + 1);

uint256 amount = 1000 * 1e6;
uint256 initialEmissaryCount = sale.currentEmissaryCount();

// Setup deposit
usdc.mint(alice, amount);
vm.prank(alice);
usdc.approve(address(sale), amount);

// Make deposit during early access
vm.prank(alice);
sale.deposit(amount, maxPrice);

// Verify emissary status
assertTrue(sale.isEmissary(alice));
assertEq(sale.currentEmissaryCount(), initialEmissaryCount + 1);
assertEq(sale.deposits(alice), amount);
}

function testEmissaryDeposit_RevertWhen_MaxEmissariesReached() public {
// Set time to early access period
vm.warp(preStartTime + 1);

uint256 amount = 1000 * 1e6;

// Fill up emissary slots
for (uint256 i = 0; i < sale.MAX_EMISSARIES(); i++) {
address emissary = address(uint160(i + 1000)); // Generate unique addresses

usdc.mint(emissary, amount);
vm.prank(emissary);
usdc.approve(address(sale), amount);

vm.prank(emissary);
sale.deposit(amount, maxPrice);
}

// Try to add one more emissary
usdc.mint(alice, amount);
vm.prank(alice);
usdc.approve(address(sale), amount);

vm.expectRevert(SealedBidTokenSale.EmissaryFull.selector);
vm.prank(alice);
sale.deposit(amount, maxPrice);
}

function testEmissaryDeposit_MultipleDeposits_SameEmissary() public {
// Set time to early access period
vm.warp(preStartTime + 1);

uint256 amount = 1000 * 1e6;
uint256 initialEmissaryCount = sale.currentEmissaryCount();

// First deposit
usdc.mint(alice, amount * 2);
vm.prank(alice);
usdc.approve(address(sale), amount * 2);

vm.prank(alice);
sale.deposit(amount, maxPrice);

// Second deposit from same emissary
vm.prank(alice);
sale.deposit(amount, maxPrice);

// Verify emissary count only increased once
assertTrue(sale.isEmissary(alice));
assertEq(sale.currentEmissaryCount(), initialEmissaryCount + 1);
assertEq(sale.deposits(alice), amount * 2);
}

function testDeposit_After_EmissaryPeriod() public {
// Set time after early access period
vm.warp(startTime + 1);

uint256 amount = 1000 * 1e6;

// Setup deposit
usdc.mint(alice, amount);
vm.prank(alice);
usdc.approve(address(sale), amount);

// Make regular deposit after early access
vm.prank(alice);
sale.deposit(amount, maxPrice);

// Verify not counted as emissary
assertFalse(sale.isEmissary(alice));
assertEq(sale.currentEmissaryCount(), 0);
assertEq(sale.deposits(alice), amount);
}

function testSaleStatus_EmissaryCount() public {
// Set time to early access period
vm.warp(preStartTime + 1);

uint256 amount = 1000 * 1e6;

// Add a few emissaries
for (uint256 i = 0; i < 3; i++) {
address emissary = address(uint160(i + 1000));

usdc.mint(emissary, amount);
vm.prank(emissary);
usdc.approve(address(sale), amount);

vm.prank(emissary);
sale.deposit(amount, maxPrice);
}

// Check emissary count in status
SealedBidTokenSale.SaleInfo memory info = sale.saleStatus(alice);
assertEq(info.currentEmissaryCount, 3);
}

function testEmissaryDeposit_Boundaries() public {
uint256 amount = 1000 * 1e6;

// Try just before preStartTime
vm.warp(preStartTime - 1);
usdc.mint(alice, amount);
vm.prank(alice);
usdc.approve(address(sale), amount);

vm.expectRevert(
abi.encodeWithSelector(SealedBidTokenSale.SaleNotStarted.selector, preStartTime - 1, preStartTime)
);
vm.prank(alice);
sale.deposit(amount, maxPrice);

// Try at exactly preStartTime
vm.warp(preStartTime);
vm.prank(alice);
sale.deposit(amount, maxPrice);
assertTrue(sale.isEmissary(alice));

// Try just before startTime
vm.warp(startTime - 1);
usdc.mint(bob, amount);
vm.prank(bob);
usdc.approve(address(sale), amount);

vm.prank(bob);
sale.deposit(amount, maxPrice);
assertTrue(sale.isEmissary(bob));

// Try at exactly startTime
vm.warp(startTime);
usdc.mint(address(0x123), amount);
vm.prank(address(0x123));
usdc.approve(address(sale), amount);

vm.prank(address(0x123));
sale.deposit(amount, maxPrice);
assertFalse(sale.isEmissary(address(0x123)));
}
}

0 comments on commit afe8f82

Please sign in to comment.