PrivPresale
Multi-stage presale contract with 10 progressive pricing stages, ETH/USDC/card payments, Chainlink oracle integration, and 20% TGE + 6-month linear vesting.
Overview
PrivPresale is a multi-stage token distribution contract that allocates 15 million PRIV across 10 pricing stages. Each stage has a fixed token supply and a progressively higher price, ranging from $0.020 to $0.150 per token. Buyers can pay with ETH (converted via Chainlink price feed), USDC, or credit card (recorded by admin via MoonPay integration). All purchases are subject to per-wallet caps and vesting: 20% unlocks at TGE, with the remaining 80% vesting linearly over 6 months.
| Property | Value |
|---|---|
| Contract | PrivPresale |
| Address (Base Sepolia) | 0xcD582BeE71c52e363EBb994BB7d87D5c774A59Fe |
| Total Allocation | 15,000,000 PRIV |
| Stages | 10 |
| Price Range | $0.020 -- $0.150 |
| TGE Unlock | 20% |
| Vesting Duration | 180 days (linear) |
| Min Purchase | $10 USD |
| Max Purchase | $50,000 USD |
| Network | Base (Chain ID: 8453) |
Stage Pricing
The presale is divided into 10 stages with increasing prices and decreasing per-wallet limits. When a stage sells out, the next stage activates automatically.
| Stage | Price (USD) | Token Allocation | Max Per Wallet | Total Raise |
|---|---|---|---|---|
| 1 | $0.020 | 2,000,000 | 500,000 | $40,000 |
| 2 | $0.025 | 2,000,000 | 500,000 | $50,000 |
| 3 | $0.030 | 1,500,000 | 400,000 | $45,000 |
| 4 | $0.040 | 1,500,000 | 400,000 | $60,000 |
| 5 | $0.050 | 1,500,000 | 300,000 | $75,000 |
| 6 | $0.060 | 1,500,000 | 300,000 | $90,000 |
| 7 | $0.075 | 1,250,000 | 250,000 | $93,750 |
| 8 | $0.090 | 1,250,000 | 250,000 | $112,500 |
| 9 | $0.120 | 1,250,000 | 200,000 | $150,000 |
| 10 | $0.150 | 1,250,000 | 200,000 | $187,500 |
Total: 15,000,000 PRIV across all stages, raising up to $903,750 if fully subscribed.
How It Works
Buyer sends ETH/USDC --> USD amount calculated --> Tokens allocated --> Stage auto-advances
| | | |
v v v v
buyWithETH() Chainlink oracle Per-wallet cap Next stage activates
buyWithUSDC() (1hr staleness) enforced when sold out
recordCardPurchase()Stage Advancement
- Each stage has a fixed
tokenAllocationand starts in order (stage 1 active at deploy). - When
tokensSold >= tokenAllocationfor the current stage, it is marked inactive. - The next stage is activated automatically and
currentStageIndexincrements. - If a purchase would exceed the remaining allocation, the buyer receives only the remaining tokens and any excess ETH is refunded.
- After all 10 stages complete,
currentStageIndexis set toNUM_STAGES(10) and no further purchases are accepted.
Payment Methods
ETH Payments
buyWithETH(stageIndex) converts the sent ETH to a USD amount using the Chainlink ETH/USD price feed. The feed must return a positive, non-stale price (updated within 1 hour). If the purchase is capped by the remaining stage allocation, excess ETH is refunded to the buyer.
function buyWithETH(uint256 stageIndex) external payable nonReentrant whenNotPausedUSDC Payments
buyWithUSDC(usdcAmount, stageIndex) treats USDC as 1:1 with USD (both use 6 decimals). Requires prior ERC-20 approval. Only the exact USDC needed is transferred from the buyer.
function buyWithUSDC(uint256 usdcAmount, uint256 stageIndex) external nonReentrant whenNotPausedCard Payments (Admin-Recorded)
recordCardPurchase(buyer, tokenAmount, usdPaid, stageIndex) is an owner-only function for off-chain credit card purchases processed via MoonPay. The admin records the buyer address, token amount, and USD paid after the off-chain payment is confirmed.
function recordCardPurchase(
address buyer,
uint256 tokenAmount,
uint256 usdPaid,
uint256 stageIndex
) external onlyOwner nonReentrant whenNotPausedChainlink Price Oracle
The contract uses a Chainlink AggregatorV3Interface for ETH/USD conversion. Three safety checks are enforced on every ETH purchase:
- Round completeness:
answeredInRound >= roundId - Positive price:
price > 0 - Staleness threshold:
block.timestamp - updatedAt <= 1 hour
If any check fails, the transaction reverts with StalePriceFeed() or InvalidPriceFeed().
Vesting Schedule
All presale purchases follow the same vesting schedule, regardless of stage or payment method.
| Phase | Unlock | Timing |
|---|---|---|
| TGE | 20% of total purchased tokens | Immediately when triggerTGE() is called |
| Linear Vesting | Remaining 80% | Linearly over 180 days after TGE |
Claiming
After TGE is triggered, buyers call claim() to withdraw their vested tokens. The claimable amount is calculated as:
tgeAmount = totalTokens * 20 / 100
vestedFromSchedule = (totalTokens - tgeAmount) * elapsed / VESTING_DURATION
totalClaimable = tgeAmount + vestedFromSchedule - alreadyClaimedBuyers can claim multiple times as tokens continue to vest. After 180 days, the full allocation is claimable.
Contract Interface
Constants
uint256 public constant TOTAL_PRESALE_TOKENS = 15_000_000 * 10 ** 18;
uint256 public constant NUM_STAGES = 10;
uint256 public constant TGE_BPS = 2000; // 20%
uint256 public constant BPS_DENOMINATOR = 10_000;
uint256 public constant VESTING_DURATION = 180 days;
uint256 public constant MIN_PURCHASE_USD = 10 * 10 ** 6; // $10
uint256 public constant MAX_PURCHASE_USD = 50_000 * 10 ** 6; // $50,000
uint256 public constant PRICE_FEED_STALENESS = 1 hours;State Variables
IERC20 public immutable privToken; // PRIV token contract
IERC20 public immutable usdc; // USDC token contract
AggregatorV3Interface public immutable ethPriceFeed; // Chainlink ETH/USD
Stage[10] public stages; // 10 presale stages
mapping(address => VestingSchedule) public vestingSchedules;
mapping(address => mapping(uint256 => uint256)) public walletStagePurchases;
uint256 public tgeTimestamp; // 0 until triggerTGE() called
uint256 public totalTokensSold;
uint256 public totalUsdRaised; // 6 decimals
uint256 public currentStageIndex;
uint256 public totalBuyers;
bool public initialized;Structs
Stage
struct Stage {
uint256 priceUsd; // Price per token in USD (6 decimals)
uint256 tokenAllocation; // Total tokens in this stage (18 decimals)
uint256 tokensSold; // Tokens sold so far (18 decimals)
uint256 maxPerWallet; // Per-wallet cap (18 decimals)
bool active; // Currently active stage
}VestingSchedule
struct VestingSchedule {
uint256 totalTokens; // Total tokens purchased
uint256 claimedTokens; // Tokens already claimed
}Functions
Buyer Functions
buyWithETH
Purchase tokens with ETH. Converts to USD via Chainlink. Refunds excess ETH.
function buyWithETH(uint256 stageIndex) external payable nonReentrant whenNotPaused| Parameter | Type | Description |
|---|---|---|
stageIndex | uint256 | Stage to purchase from (0-9) |
Requirements:
- Contract must be initialized
msg.value > 0- Stage must be active
- USD equivalent must be between $10 and $50,000
- Wallet must not exceed per-stage cap
buyWithUSDC
Purchase tokens with USDC. Requires prior approval.
function buyWithUSDC(uint256 usdcAmount, uint256 stageIndex) external nonReentrant whenNotPaused| Parameter | Type | Description |
|---|---|---|
usdcAmount | uint256 | USDC to spend (6 decimals) |
stageIndex | uint256 | Stage to purchase from (0-9) |
Requirements:
- Contract must be initialized
usdcAmount > 0- Sufficient USDC allowance granted to contract
claim
Claim vested tokens after TGE.
function claim() external nonReentrantRequirements:
- TGE must have been triggered
- Caller must have claimable tokens greater than 0
View Functions
getCurrentStage
Returns the current active stage details.
function getCurrentStage() external view returns (
uint256 stageIndex,
uint256 priceUsd,
uint256 tokenAllocation,
uint256 tokensSold,
uint256 maxPerWallet,
bool isActive
)getStageInfo
Returns details for a specific stage.
function getStageInfo(uint256 stageIndex) external view returns (
uint256 priceUsd,
uint256 tokenAllocation,
uint256 tokensSold,
uint256 maxPerWallet,
bool active
)getAllStages
Returns all 10 stage configurations.
function getAllStages() external view returns (Stage[10] memory)getUserAllocation
Returns a user's purchase and vesting details.
function getUserAllocation(address user) external view returns (
uint256 totalTokens,
uint256 claimedTokens,
uint256 claimableTokens,
uint256 vestingStart,
uint256 vestingEnd
)getClaimable
Returns the number of tokens currently claimable by a user.
function getClaimable(address user) external view returns (uint256)getEthPrice
Returns the current ETH/USD price from Chainlink (8 decimals).
function getEthPrice() external view returns (uint256)calculateTokensForUsd
Calculates how many tokens a given USD amount would purchase at the current stage.
function calculateTokensForUsd(uint256 usdAmount) external view returns (uint256)Admin Functions
initialize
Verifies the contract holds at least 15M PRIV and marks the presale as initialized. Must be called before any purchases.
function initialize() external onlyOwnertriggerTGE
Triggers the Token Generation Event, starting the vesting clock for all buyers. Can only be called once.
function triggerTGE() external onlyOwnerwithdrawETH
Withdraws all collected ETH to a specified address.
function withdrawETH(address to) external onlyOwnerwithdrawTokens
Withdraws collected USDC or other ERC-20 tokens. When withdrawing PRIV, only unallocated tokens (above total sold) can be withdrawn.
function withdrawTokens(address token, address to) external onlyOwnerpause / unpause
Emergency pause controls. Pausing blocks all purchases and card recordings.
function pause() external onlyOwner
function unpause() external onlyOwnerEvents
event PresaleInitialized(uint256 timestamp);
event TokensPurchased(
address indexed buyer,
uint256 indexed stageIndex,
uint256 tokenAmount,
uint256 usdAmount,
string paymentMethod // "eth", "usdc", or "card"
);
event StageCompleted(uint256 indexed stageIndex, uint256 tokensSold);
event StageActivated(uint256 indexed stageIndex, uint256 priceUsd);
event TGETriggered(uint256 timestamp);
event TokensClaimed(address indexed buyer, uint256 amount);
event FundsWithdrawn(address indexed token, address indexed to, uint256 amount);
event ETHWithdrawn(address indexed to, uint256 amount);Errors
error InvalidAddress();
error AlreadyInitialized();
error NotInitialized();
error StageNotActive();
error InvalidStageIndex();
error StageSoldOut();
error BelowMinPurchase(); // Below $10
error AboveMaxPurchase(); // Above $50,000
error WalletLimitExceeded();
error InsufficientPayment();
error TGEAlreadyTriggered();
error TGENotTriggered();
error NothingToClaim();
error StalePriceFeed(); // Chainlink data > 1 hour old
error InvalidPriceFeed(); // Chainlink returned <= 0
error PresaleComplete();
error InsufficientTokenBalance(); // Contract does not hold 15M PRIV
error ZeroAmount();
error RefundFailed(); // ETH refund transfer failedUsage Examples
Buy with ETH
import { useWriteContract } from 'wagmi'
import { parseEther } from 'viem'
function useBuyWithETH() {
const { writeContract } = useWriteContract()
return (ethAmount: string, stageIndex: number) => {
writeContract({
address: PRESALE_ADDRESS,
abi: presaleAbi,
functionName: 'buyWithETH',
args: [BigInt(stageIndex)],
value: parseEther(ethAmount),
})
}
}Buy with USDC
import { useWriteContract } from 'wagmi'
function useBuyWithUSDC() {
const { writeContract } = useWriteContract()
return async (usdcAmount: bigint, stageIndex: number) => {
// 1. Approve USDC spending
await writeContract({
address: USDC_ADDRESS,
abi: erc20Abi,
functionName: 'approve',
args: [PRESALE_ADDRESS, usdcAmount],
})
// 2. Execute purchase
await writeContract({
address: PRESALE_ADDRESS,
abi: presaleAbi,
functionName: 'buyWithUSDC',
args: [usdcAmount, BigInt(stageIndex)],
})
}
}Check Allocation and Claim
import { useReadContract, useWriteContract } from 'wagmi'
function usePresaleAllocation(address: string) {
const { data: allocation } = useReadContract({
address: PRESALE_ADDRESS,
abi: presaleAbi,
functionName: 'getUserAllocation',
args: [address],
})
const { writeContract } = useWriteContract()
const claim = () => {
writeContract({
address: PRESALE_ADDRESS,
abi: presaleAbi,
functionName: 'claim',
})
}
return { allocation, claim }
}Read Current Stage
function useCurrentStage() {
const { data: stage } = useReadContract({
address: PRESALE_ADDRESS,
abi: presaleAbi,
functionName: 'getCurrentStage',
})
// stage = [stageIndex, priceUsd, tokenAllocation, tokensSold, maxPerWallet, isActive]
return stage
}Security Notes
-
Chainlink Staleness Protection: ETH purchases revert if the price feed is older than 1 hour, preventing stale-price exploits.
-
Per-Wallet Caps: Each stage enforces a maximum token purchase per wallet address, tracked independently per stage via
walletStagePurchases. -
ETH Refund Safety: If a purchase is capped by remaining stage allocation, excess ETH is refunded. If the refund transfer fails, the entire transaction reverts.
-
Reentrancy Protection: All purchase and claim functions use the
nonReentrantmodifier from OpenZeppelin. -
PRIV Token Protection: The
withdrawTokensfunction prevents the owner from withdrawing PRIV tokens that are allocated to buyers. Only unallocated surplus can be removed. -
Emergency Pause: The owner can pause all purchases in an emergency. Claiming is not affected by pause (it uses
nonReentrantonly, notwhenNotPaused). -
Direct ETH Rejection: The
receive()function reverts, forcing buyers to usebuyWithETH()with a valid stage index.