Fun Offering
A casino-styled token presale contract for BNB Smart Chain. Buyers pick a tier, pay in BNB, and receive a random amount of tokens within that tier's range — powered by Chainlink VRF for provably fair on-chain randomness. Payments are forwarded instantly to the contract owner.
Overview
This project contains two parts:
- Smart contracts — A Foundry project with the
FunOfferingpresale contract, aDummyTokenfor testing, deployment scripts, and a comprehensive test suite. - Frontend — A React + TypeScript single-page widget built with Vite, styled with Tailwind CSS. Connects wallets via Reown AppKit (WalletConnect). Designed to run standalone or be embedded via iframe.
What the contract does:
- Defines purchase tiers with USD price ranges and token ranges
- Accepts BNB payments (converted to USD via Chainlink price feed) or whitelisted stablecoins
- Requests a random number from Chainlink VRF v2.5 for each purchase
- Distributes a random token amount within the tier's range to the buyer
- Forwards payments to the contract owner instantly (no funds held)
- Enforces a total distribution cap
Prerequisites
- Foundry — Solidity development framework. Install:
curl -L https://foundry.paradigm.xyz | bash && foundryup - Node.js — v18 or later (for the frontend). Comes with npm.
- A wallet with BNB for gas fees on BSC Testnet or Mainnet
- LINK tokens on BSC for funding the Chainlink VRF subscription
- A Reown project ID — Free at cloud.reown.com (for WalletConnect)
Installation
Purchase & download
Purchase the source code on web3.market. You'll receive a .zip file.
Extract the archive
unzip fun-offering.zip -d fun-offering
cd fun-offering
Install Foundry dependencies
forge install
This pulls OpenZeppelin, Chainlink, and forge-std into the lib/ directory.
Set up environment variables
cp .env.example .env
Edit .env with your values:
# RPC endpoints
BSC_RPC_URL=https://bsc-dataseed1.binance.org
BSC_TESTNET_RPC_URL=https://data-seed-prebsc-1-s1.binance.org:8545
# Your BSCScan API key (for contract verification)
BSCSCAN_API_KEY=your_api_key_here
# Deployer wallet private key (with BNB for gas)
DEPLOYER_PRIVATE_KEY=your_private_key_here
Build & test the contracts
forge build
forge test -vvv
All tests should pass. The test suite includes unit tests, fuzz tests, and integration tests.
Install frontend dependencies
cd frontend
npm install
Project Structure
Contract Overview
FunOffering.sol is the main contract. It inherits from:
| Parent | Source | Purpose |
|---|---|---|
VRFConsumerBaseV2Plus | Chainlink | Receives VRF random numbers. Also provides ownership via ConfirmedOwner. |
ReentrancyGuard | OpenZeppelin v5 | Protects against reentrancy on buy functions. |
Pausable | OpenZeppelin v5 | Emergency pause capability. |
ConfirmedOwner (part of VRFConsumerBaseV2Plus), not from OpenZeppelin's Ownable.
Constructor parameters:
| Parameter | Type | Description |
|---|---|---|
_offeringToken | address | The ERC-20 token to distribute to buyers |
_vrfCoordinator | address | Chainlink VRF v2.5 coordinator address |
_priceFeed | address | Chainlink BNB/USD price feed address |
_vrfSubscriptionId | uint256 | Your Chainlink VRF subscription ID |
_vrfKeyHash | bytes32 | VRF gas lane key hash |
_vrfCallbackGasLimit | uint32 | Gas limit for the VRF callback (recommended: 300000) |
_vrfRequestConfirmations | uint16 | Block confirmations before VRF response (recommended: 3) |
_maxTotalDistribution | uint256 | Maximum total tokens to distribute (18 decimals) |
Deploy Your Token
You can use any existing ERC-20 token, or deploy the included DummyToken for testing. The DummyToken mints 10,000,000 tokens to the deployer and has an owner-only mint() function.
# Deploy DummyToken to BSC Testnet
forge script script/DeployAll.s.sol:DeployDummyToken \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast
Save the deployed token address — you'll need it for the next step.
Chainlink VRF Setup
Before deploying the main contract, you need a Chainlink VRF v2.5 subscription:
Create a VRF subscription
Go to vrf.chain.link and create a new subscription on the BSC network (testnet or mainnet).
Fund the subscription
Add LINK tokens to your subscription. The contract uses LINK (not native BNB) to pay for VRF requests. Each request costs a small amount of LINK.
Note your subscription ID
You'll find the subscription ID on the VRF dashboard. This is a large number — you'll need it for the deployment script.
Deploy FunOffering
The deployment script (script/DeployAll.s.sol) is pre-configured for BSC Testnet. For mainnet, you'll need to update the hardcoded addresses in the script (see Network Addresses).
Set additional environment variables
# Add to your .env file
OFFERING_TOKEN=0xYourTokenAddress
VRF_SUBSCRIPTION_ID=your_subscription_id_number
Review the deployment parameters
Open script/DeployAll.s.sol and verify these values in the DeployFunOffering contract:
// Callback gas limit: 300,000 is sufficient for most cases
300_000,
// Request confirmations: 3 blocks
3,
// Max distribution cap (18 decimals)
1_000_000e18 // = 1,000,000 tokens
Change the maxTotalDistribution value to match the total tokens you want to distribute during your presale.
Deploy
# Load env vars and deploy to BSC Testnet
source .env
forge script script/DeployAll.s.sol:DeployFunOffering \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast
The console will print the deployed contract address.
Verify on BSCScan (optional)
forge verify-contract <CONTRACT_ADDRESS> src/FunOffering.sol:FunOffering \
--chain-id 97 \
--constructor-args $(cast abi-encode "constructor(address,address,address,uint256,bytes32,uint32,uint16,uint256)" \
$OFFERING_TOKEN \
0xDA3b641D438362C440Ac5458c57e00a712b66700 \
0x2514895c72f50D8bd4B4F9b1110F0D6bD2c97526 \
$VRF_SUBSCRIPTION_ID \
0x8596b430971ac45bdf6088665b9ad8e8630c9d5049ab54b14dff711bee7c0e26 \
300000 \
3 \
1000000000000000000000000) \
--etherscan-api-key $BSCSCAN_API_KEY
Deploying to BSC Mainnet
To deploy to mainnet, edit the addresses in script/DeployAll.s.sol:
// Change from BSC Testnet to BSC Mainnet
address vrfCoordinator = 0xd691f04bc0C9a24Edb78af9E005Cf85768F694C9;
address priceFeed = 0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE;
// Use the mainnet key hash from Chainlink docs
bytes32 keyHash = 0x...; // see docs.chain.link for BSC mainnet key hashes
Then deploy using $BSC_RPC_URL instead of the testnet URL.
Post-Deployment Setup
After deploying the contract, you need to complete these steps from the owner wallet:
Add the contract as a VRF consumer
Go to vrf.chain.link, open your subscription, and click "Add consumer". Paste the deployed FunOffering contract address.
Add purchase tiers
Call addTier() for each tier you want. See Configure Tiers for details.
Fund the contract with tokens
Transfer your offering tokens directly to the contract address using a standard ERC-20 transfer(). The contract distributes tokens from its own balance.
# Example using cast (Foundry CLI)
cast send $OFFERING_TOKEN \
"transfer(address,uint256)" \
<FUNOFFERING_ADDRESS> \
1000000000000000000000000 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
The value above is 1,000,000 tokens (with 18 decimals). Make sure the contract holds at least maxTotalDistribution tokens.
Configure Tiers
Tiers define the purchase ranges. Each tier has:
| Field | Decimals | Description |
|---|---|---|
minBuyUsd | 8 | Minimum purchase in USD |
maxBuyUsd | 8 | Maximum purchase in USD |
minTokens | 18 | Minimum tokens the buyer can receive |
maxTokens | 18 | Maximum tokens the buyer can receive |
10_00000000 (1,000,000,000). Token values use 18 decimals, so 1,000 tokens = 1000_000000000000000000 (1000e18).
Adding tiers with cast
# Add a tier: $10-$50 buy-in, 1,000-5,000 tokens
cast send <CONTRACT> \
"addTier(uint256,uint256,uint256,uint256)" \
1000000000 5000000000 1000000000000000000000 5000000000000000000000 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
# Add a tier: $50-$200 buy-in, 5,000-20,000 tokens
cast send <CONTRACT> \
"addTier(uint256,uint256,uint256,uint256)" \
5000000000 20000000000 5000000000000000000000 20000000000000000000000 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
# Add a tier: $200-$1000 buy-in, 20,000-100,000 tokens
cast send <CONTRACT> \
"addTier(uint256,uint256,uint256,uint256)" \
20000000000 100000000000 20000000000000000000000 100000000000000000000000 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
Updating an existing tier
# Update tier 0: new prices and token range, keep active
cast send <CONTRACT> \
"updateTier(uint256,uint256,uint256,uint256,uint256,bool)" \
0 \
2000000000 10000000000 \
2000000000000000000000 10000000000000000000000 \
true \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
Disabling a tier
# Deactivate tier 2
cast send <CONTRACT> \
"setTierActive(uint256,bool)" 2 false \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
App.tsx — see Customize UI).
Stablecoin Payments
The contract supports purchases with whitelisted stablecoins via buyWithToken(). Stablecoin amounts are normalized to 8-decimal USD values for comparison against tier bounds.
# Whitelist USDT on BSC Mainnet
cast send <CONTRACT> \
"setWhitelistedStablecoin(address,bool)" \
0x55d398326f99059fF775485246999027B3197955 true \
--rpc-url $BSC_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
# Whitelist USDC on BSC Mainnet
cast send <CONTRACT> \
"setWhitelistedStablecoin(address,bool)" \
0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d true \
--rpc-url $BSC_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
buyWithToken() function can be called directly via BSCScan or a script. Stablecoin payments require the buyer to first approve() the contract to spend their tokens.
Distribution Cap
The maxTotalDistribution value limits how many tokens can be distributed in total. Once reached, no more purchases are possible.
# Check current distribution
cast call <CONTRACT> "totalDistributed()" --rpc-url $BSC_TESTNET_RPC_URL
cast call <CONTRACT> "maxTotalDistribution()" --rpc-url $BSC_TESTNET_RPC_URL
# Increase the cap to 2,000,000 tokens
cast send <CONTRACT> \
"setMaxTotalDistribution(uint256)" \
2000000000000000000000000 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
The frontend displays a progress bar showing totalDistributed / maxTotalDistribution.
VRF Parameters
These can be adjusted after deployment if needed:
# Increase callback gas limit (if VRF callbacks are failing)
cast send <CONTRACT> \
"setVrfCallbackGasLimit(uint32)" 400000 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
# Change request confirmations
cast send <CONTRACT> \
"setVrfRequestConfirmations(uint16)" 5 \
--rpc-url $BSC_TESTNET_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
| Parameter | Default | Notes |
|---|---|---|
vrfCallbackGasLimit | 300,000 | Increase if callbacks run out of gas. Max depends on the coordinator. |
vrfRequestConfirmations | 3 | More confirmations = slower but more secure. Minimum is usually 3. |
Frontend Setup & Run
Configure environment variables
cd frontend
cp .env.example .env
Edit frontend/.env:
# Your deployed FunOffering contract address
VITE_CONTRACT_ADDRESS=0xYourContractAddress
# Reown (WalletConnect) project ID from cloud.reown.com
VITE_WALLETCONNECT_PROJECT_ID=your_project_id
Install and run
npm install
npm run dev
The app will be available at http://localhost:5173.
Build for production
npm run build
Output goes to frontend/dist/.
Available scripts
| Command | Description |
|---|---|
npm run dev | Start development server with hot reload |
npm run build | Type-check and build for production |
npm run preview | Preview the production build locally |
npm run test | Run tests once |
npm run test:watch | Run tests in watch mode |
npm run lint | Run ESLint |
Deploy to Vercel
Push to GitHub
Push your repository to GitHub (or GitLab/Bitbucket).
Import in Vercel
Go to vercel.com/new and import your repository.
Set the Root Directory to frontend in the project settings.
Add environment variables
In the Vercel project settings, add:
| Key | Value |
|---|---|
VITE_CONTRACT_ADDRESS | Your deployed contract address |
VITE_WALLETCONNECT_PROJECT_ID | Your Reown project ID |
Deploy
Vercel will auto-detect Vite and build. Your app will be live at your-project.vercel.app.
This documentation page will be available at your-project.vercel.app/docs/.
npm run build. Output directory: dist.
Customize UI
Tier names and colors
The tier display names and colors are defined in frontend/src/App.tsx:
const TIER_META = [
{ name: 'BRNZ', color: '#cd7f32' }, // Tier 0 - Bronze
{ name: 'SLVR', color: '#8892a4' }, // Tier 1 - Silver
{ name: 'GOLD', color: '#ffb800' }, // Tier 2 - Gold
{ name: 'DMND', color: '#00ff41' }, // Tier 3 - Diamond
]
Edit the name and color values to match your branding. Names are displayed as short labels (4 characters recommended). If you have fewer tiers, the unused entries are simply ignored. Add more entries if you have more than 4 tiers.
Title and metadata
The app title appears in several places:
- Page title:
frontend/index.html— the<title>tag - Widget heading: In
App.tsx, search for$FUN OFFERING - Wallet modal metadata: In
config/wagmi.ts, themetadataobject passed tocreateAppKit()
Colors and styling
All styles are in frontend/src/index.css. The main accent color #00ff41 (neon green) appears throughout. To change it, do a find-and-replace in that file. Key color values:
| Color | Usage |
|---|---|
#00ff41 | Primary accent (buttons, highlights, status indicators) |
#00e5ff | Secondary accent (slot machine glow) |
#ffb800 | Amber (loading states, jackpot effects) |
#050505 / #0a0a0a | Background colors |
The wallet modal accent is set separately in config/wagmi.ts:
createAppKit({
// ...
themeMode: 'dark',
themeVariables: {
'--apkt-accent': '#00ff41',
},
})
Fonts
The app uses two Google Fonts loaded in index.html:
- Orbitron — Display font for the title, buttons, and slot machine numbers
- Share Tech Mono — Monospace font for the body text and labels
To change fonts, update the Google Fonts <link> in index.html and the font-family rules in index.css.
Sound effects
All sounds are synthesized via the Web Audio API in lib/sounds.ts — no audio files are needed. You can adjust frequencies, durations, and waveforms there. Users can mute sounds with the toggle button in the widget header.
Iframe Embedding
The widget can be embedded in any website via iframe. Two URL parameters are supported:
| Parameter | Description |
|---|---|
?embed=true | Removes the background and outer padding for seamless embedding |
?contract=0x... | Overrides the contract address (useful for multiple deployments) |
<!-- Embed example -->
<iframe
src="https://your-app.vercel.app/?embed=true"
width="500"
height="800"
style="border: none; border-radius: 12px;"
></iframe>
<!-- With contract override -->
<iframe
src="https://your-app.vercel.app/?embed=true&contract=0xABC..."
width="500"
height="800"
style="border: none;"
></iframe>
Owner Functions
All administrative functions are restricted to the contract owner (the deployer address). These can be called using cast send or via BSCScan's "Write Contract" interface.
| Function | Description |
|---|---|
addTier() | Add a new purchase tier |
updateTier() | Update all parameters of an existing tier |
setTierActive() | Enable or disable a tier |
setWhitelistedStablecoin() | Whitelist or remove a stablecoin |
setMaxTotalDistribution() | Change the distribution cap |
setVrfCallbackGasLimit() | Adjust VRF callback gas |
setVrfRequestConfirmations() | Adjust VRF block confirmations |
withdrawBNB() | Withdraw any BNB held by the contract |
withdrawTokens() | Withdraw any ERC-20 tokens from the contract |
pause() | Pause all purchases |
unpause() | Resume purchases |
Pause & Unpause
The contract can be paused to prevent new purchases. Pending VRF callbacks will still be processed.
# Pause the contract
cast send <CONTRACT> "pause()" \
--rpc-url $BSC_TESTNET_RPC_URL --private-key $DEPLOYER_PRIVATE_KEY
# Unpause the contract
cast send <CONTRACT> "unpause()" \
--rpc-url $BSC_TESTNET_RPC_URL --private-key $DEPLOYER_PRIVATE_KEY
Withdraw Funds
Normally, BNB payments are forwarded to the owner address instantly during each purchase. These withdrawal functions are for edge cases where funds end up in the contract.
# Withdraw any BNB balance to a specific address
cast send <CONTRACT> "withdrawBNB(address)" <RECIPIENT> \
--rpc-url $BSC_TESTNET_RPC_URL --private-key $DEPLOYER_PRIVATE_KEY
# Withdraw any ERC-20 token balance
cast send <CONTRACT> "withdrawTokens(address,address)" <TOKEN> <RECIPIENT> \
--rpc-url $BSC_TESTNET_RPC_URL --private-key $DEPLOYER_PRIVATE_KEY
Architecture
Smart contract
- Solidity 0.8.28 compiled with the Paris EVM version
- Chainlink VRF v2.5 for verifiable randomness (pays in LINK, not native BNB)
- Chainlink Price Feed (BNB/USD) for converting BNB payments to USD values
- OpenZeppelin v5.6.0 for ReentrancyGuard, Pausable, and SafeERC20
- USD values use 8 decimals throughout (matching Chainlink's format)
- Token amounts use 18 decimals (standard ERC-20)
- Price staleness check: reverts if the price feed is older than 1 hour
Frontend
- React 19 + TypeScript 5.9, built with Vite 7
- Tailwind CSS 4 (via Vite plugin, no config file needed)
- wagmi v2 + viem v2 for contract interactions
- Reown AppKit for wallet connection (WalletConnect, MetaMask, Coinbase, etc.)
- Motion (Framer Motion) for UI animations
- canvas-confetti for celebration effects
- All sound effects synthesized via Web Audio API (no audio files)
Purchase Flow
Here's what happens when a user buys tokens:
User selects a tier and enters a BNB amount
The frontend calculates the USD equivalent using the on-chain price feed and validates it falls within the tier's USD range.
Transaction is sent
The user's wallet prompts to confirm a buyWithBNB(tierId) transaction with the BNB amount as msg.value.
Contract processes payment
The contract converts the BNB to USD via the Chainlink price feed, validates the amount, forwards the BNB to the owner, and requests a random number from Chainlink VRF.
VRF callback
After a few blocks, Chainlink delivers a random number. The contract calculates: minTokens + (randomWord % (maxTokens - minTokens + 1)) and transfers that many tokens to the buyer.
Frontend reveals the result
The frontend watches for the PurchaseRevealed event. When it arrives, the slot machine animation decelerates and locks in the final amount, followed by confetti.
Frontend state machine
idle → buying → spinning → revealing → revealed
↑ |
←—————————— reset —————————————←
idle → User is browsing tiers and entering amounts
buying → Transaction submitted, waiting for wallet confirmation
spinning → Transaction confirmed, waiting for Chainlink VRF response
revealing → VRF arrived, slot machine ramp-down animation playing
revealed → Final amount displayed, confetti, share button visible
Contract API
Write functions
| Function | Params | Description |
|---|---|---|
buyWithBNB(uint256) |
_tierId + BNB as msg.value |
Purchase tokens with BNB. Payable. |
buyWithToken(uint256,address,uint256) |
_tierId, _token, _amount |
Purchase with a whitelisted stablecoin. Requires prior approval. |
Read functions
| Function | Returns | Description |
|---|---|---|
getTierCount() | uint256 | Number of tiers |
getTier(uint256) | Tier | Tier struct (minBuyUsd, maxBuyUsd, minTokens, maxTokens, isActive) |
totalDistributed() | uint256 | Total tokens distributed so far (18 decimals) |
maxTotalDistribution() | uint256 | Distribution cap (18 decimals) |
getLatestBnbUsdPrice() | int256 | Current BNB/USD price (8 decimals) |
offeringToken() | address | The ERC-20 token being distributed |
owner() | address | Contract owner address |
Events
| Event | Indexed | Fields |
|---|---|---|
PurchaseRequested |
requestId, buyer | tierId, paymentAmount, paymentToken |
PurchaseRevealed |
requestId, buyer | tierId, tokenAmount |
TierAdded | tierId | minBuyUsd, maxBuyUsd, minTokens, maxTokens |
TierUpdated | tierId | — |
StablecoinWhitelisted | token | status |
Network Addresses
BSC Testnet (Chain ID: 97)
| Resource | Address |
|---|---|
| VRF Coordinator | 0xDA3b641D438362C440Ac5458c57e00a712b66700 |
| BNB/USD Price Feed | 0x2514895c72f50D8bd4B4F9b1110F0D6bD2c97526 |
| VRF Key Hash | 0x8596b430971ac45bdf6088665b9ad8e8630c9d5049ab54b14dff711bee7c0e26 |
BSC Mainnet (Chain ID: 56)
| Resource | Address |
|---|---|
| VRF Coordinator | 0xd691f04bc0C9a24Edb78af9E005Cf85768F694C9 |
| BNB/USD Price Feed | 0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE |
| VRF Key Hash | See Chainlink docs |
| USDT | 0x55d398326f99059fF775485246999027B3197955 |
| USDC | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d |
Running Tests
Smart contract tests
# Run all tests with verbose output
forge test -vvv
# Run a specific test
forge test --match-test test_buyWithBNB_success -vvv
# Run with gas reporting
forge test --gas-report
The test suite covers:
- Tier management (add, update, activate/deactivate)
- BNB purchases (success, too low, too high, inactive tier)
- Stablecoin purchases (success, not whitelisted)
- Full VRF buy-and-reveal flow
- Distribution cap enforcement
- Price feed staleness checks
- Access control for all owner functions
- Pause functionality
- Withdrawals (BNB and tokens)
- Fuzz tests for token amount ranges and payment validation (256 runs each)
Frontend tests
cd frontend
npm test
The frontend has basic rendering tests using Vitest and React Testing Library.
Built with Foundry, React, and Chainlink VRF