Pools
Pools do not have their own contract. They share the one launchpad address with microsales and Melt Pools — one address per chain, one ABI, all three launch styles.
| Network | Chain ID | Launchpad |
|---|---|---|
| BNB Chain (mainnet) | 56 | 0xD9CcE715619fA9082d21375B6B37A3a0d99138F4 |
| BNB Chain (testnet) | 97 | 0x0AA21d47b600B04527c09BbD9A30aF32cb64f6cF |
| Robinhood Chain | 4663 | 0x7E0752B42f9db92F12E221E10fFfa10bfE8668C6 |
An individual pool is identified by its fundManager address, which you pass as an argument to the
calls below. That is a per-sale handle, not a contract you call.
A pool is a sale for a token you already have. You deploy your ERC20, approve the launchpad to pull what the sale needs, and the launchpad runs the raise and lists the token. This is the path to use when you own the token contract and want control over price, caps, whitelist, and vesting. If you want the launchpad to mint the token for you instead, see microsale.md.
Pools come in three shapes. They share most of the machinery but differ in how price is set.
Contents
- The pieces
- Pool types
- Payment currencies
- Before you create: approve your token
- Creating a presale
- Creating a fair launch
- Creating a subscription
- Whitelisting
- Contributing
- Finalizing
- Claiming and vesting
- Refunds and exits
- Subscription allocation
- Reading pool state
- Error reference
The pieces
- Your token. You bring the ERC20. The launchpad never mints it. At create time it pulls the presale allocation plus the liquidity allocation from your wallet into the pool's fund manager, so you approve first.
- The pool. One sale, identified by its
fundManageraddress, the handle you pass to almost every call. - The fund manager. A per-pool address that escrows your tokens and the raised funds until finalize. You never call it directly.
- The owner. By default the
owneryou set in the create params (it can differ from the wallet that sends the create transaction). The owner finalizes, cancels, edits, and sets the whitelist.
A pool is always Active (0), Finalized (1), or Cancelled (2).
Pool types
| Type | Price | Raise measured in | Finalizes when |
|---|---|---|---|
| Presale | fixed rate you set | currency, up to hardCap | cap hit, or soft cap met at end time |
| Fair Launch | emergent: total raised divided by a fixed token supply | currency, above softCap | soft cap met at end time |
| Subscription | fixed rate, but oversubscribed and allocated afterward | tokens committed vs softCapTokens | soft cap met and allocation approved |
- Presale is the classic fixed-price sale. Contributors buy tokens at
rateuntil the hard cap fills. Everyone who gets in pays the same price. - Fair Launch does not fix a price. You commit a fixed
totalSellingTokens, contributors put in as much as the rules allow, and the final price is whatever the total raise implies. More raised means a higher price per token. - Subscription lets people commit more than the sale can fill, then allocates pro-rata (or by whatever formula the platform runs off-chain), publishes the result as a Merkle root, and refunds the excess when contributors claim. Use it when you expect heavy oversubscription and want a fair split.
Two overflow variants (OverflowPresale, OverflowFairLaunch) exist in the claim and withdraw
surface, but there is no create path for them today, so you will only meet them if you interact with
a pool someone created through another route. The three types above are the ones you can launch.
Payment currencies
Presale, fair-launch, and subscription pools raise in the chain's native coin or a supported ERC20
from the launchpad's currency allow-list — a separate list from the
Melt Pool / microsale quote tokens. Pass the chosen token as currency
in the create params (address(0) for native), and read the live set with getAllowedCurrencies().
See Supported currencies for the full per-chain list.
Before you create: approve your token
Every create call pulls tokens from the sender. Compute how many with the matching helper, then approve the launchpad for that amount.
// Presale example; there are calculateFairLaunchTotalTokens and calculateSubscriptionTotalTokens too
const needed = await launchpad.calculatePresaleTotalTokens(params);
const token = new ethers.Contract(params.token, ERC20_ABI, signer);
await token.approve(LAUNCHPAD, needed);
The total covers the presale allocation plus the liquidity tokens (unless the pool is manual
listing). Fee-on-transfer and rebasing tokens are rejected: the launchpad checks that the fund
manager received exactly needed, and reverts Transfer not enough token to pool otherwise. If the
platform charges a pool creation fee, send it as the exact msg.value on the create call.
Creating a presale
createPresale(CreatePresaleParams params) is payable and permissionless.
| Field | Type | Notes |
|---|---|---|
owner | address | who owns the pool afterward; can differ from the sender |
router | address | a supported DEX router, or address(0) for a manual-listing pool |
token | address | your ERC20 |
currency | address | raise currency; address(0) for native, else a supported ERC20 |
startTime, endTime | uint256 | sale window; set both or leave both at 0 |
publicSaleStartTime | uint256 | when the whitelist window ends and the public phase opens |
rate | uint256 | presale price: tokens per one whole currency unit |
listingRate | uint256 | DEX listing price; 0 for manual listing |
minContribution, maxContribution | uint256 | per-wallet bounds in the raise currency |
softCap, hardCap | uint256 | funding targets; softCap * 4 >= hardCap and softCap < hardCap |
lockDuration | uint256 | LP lock seconds; 0 or at least the chain minimum |
listingPercent | uint256 | percent of the raise used for liquidity, 20 to 100 |
refundType | enum | Refund (0) returns unsold tokens, Burn (1) burns them |
poolDetails | string | free-form metadata |
affiliateBps | uint256 | affiliate reward in BPS; 0 to disable, capped by the platform |
tgePercent, cyclePercent, cycle | uint256 | vesting; see Claiming and vesting |
isManualListing | bool | true skips auto-listing; then router, lock, and listing fields must be 0 |
const params = {
owner: myAddress,
router: ROUTER,
token: TOKEN,
currency: ethers.ZeroAddress, // native raise
startTime: start, endTime: end,
publicSaleStartTime: start, // no whitelist window
rate: ethers.parseEther("1000"), // 1000 tokens per 1 BNB
listingRate: ethers.parseEther("800"),
minContribution: ethers.parseEther("0.1"),
maxContribution: ethers.parseEther("5"),
softCap: ethers.parseEther("50"),
hardCap: ethers.parseEther("100"),
lockDuration: 0n, // burn LP
listingPercent: 60n, // 60% of the raise into liquidity
refundType: 0, // refund unsold tokens
poolDetails: "",
affiliateBps: 0n,
tgePercent: 0n, cyclePercent: 0n, cycle: 0n, // no vesting
isManualListing: false,
};
const fee = (await launchpad.getMasterConfig()).creationFee;
const tx = await launchpadRW.createPresale(params, { value: fee });
const rc = await tx.wait();
const created = rc.logs.find((l) => l.eventName === "PoolCreated");
const fundManager = created.args.fundManager;
PoolCreated(index, fundManager, owner, token, timestamp) gives you the fundManager handle. The
owner in the event is the sender; the pool's stored owner is params.owner.
Creating a fair launch
createFairLaunch(CreateFairLaunchParams params). Same idea, but you fix the token supply and let
the price float. There is no rate, hardCap, or minContribution.
| Field | Type | Notes |
|---|---|---|
owner, router, token, currency | address | as in presale |
startTime, endTime | uint256 | duration must be within the platform's fair-launch maximum |
publicSaleStartTime | uint256 | whitelist window end |
maxContribution | uint256 | per-wallet cap in the raise currency |
softCap | uint256 | minimum raise for the sale to succeed |
totalSellingTokens | uint256 | the fixed supply on offer; must be above zero |
lockDuration, listingPercent | uint256 | as in presale (0 for manual listing) |
poolDetails | string | metadata |
affiliateBps | uint256 | affiliate BPS, capped |
tgePercent, cyclePercent, cycle | uint256 | vesting |
isManualListing | bool | as in presale |
The refund handling is fixed to Refund. The final per-token price is the total raised divided by
totalSellingTokens, so contributors do not know their exact allocation until the sale ends.
Creating a subscription
createSubscription(CreateSubscriptionParams params). Built for oversubscription: contributors
commit, then the platform allocates and publishes the result. There is no manual-listing option, and
LP is always locked.
| Field | Type | Notes |
|---|---|---|
owner, router, token, currency | address | router is required (no manual listing) |
startTime, endTime, publicSaleStartTime | uint256 | as usual |
rate | uint256 | price used to derive the implied cap |
listingRate | uint256 | DEX listing price |
maxContribution | uint256 | per-wallet cap in the raise currency |
softCapTokens | uint256 | soft cap in tokens; above zero and below totalSellingTokens |
totalSellingTokens | uint256 | total tokens on offer |
hardCapTokensPerUser | uint256 | per-wallet token cap; above zero |
lockDuration | uint256 | required, at least the chain minimum |
listingPercent | uint256 | 20 to 100 |
refundType | enum | Refund or Burn |
poolDetails | string | metadata |
After the sale ends, the pool needs an allocation before it can finalize or contributors can claim. That step is covered in Subscription allocation.
Whitelisting
Whitelisting is optional and Merkle-based. If publicSaleStartTime equals the sale start, there is
no whitelist window and anyone can contribute from the start. To gate the early window, the owner
sets a Merkle root and (optionally) time-boxed tiers.
// owner only
await launchpadRW.setWhitelistRoot(fundManager, merkleRoot);
// optional tiers, and/or set the public phase start in one call
await launchpadRW.setPublicSaleStartTimeAndWhitelistTiers(fundManager, publicStart, tiers);
What a contributor passes during the whitelist window: the same (index, tier, allocation) values
used to build their Merkle leaf, plus the proof. The leaf is the standard double-hashed encoding:
leaf = keccak256(bytes.concat(keccak256(abi.encode(index, user, tier, allocation))))
allocation, when above zero, becomes that wallet's contribution cap for the whitelist window. Once
block.timestamp >= publicSaleStartTime, the whitelist is not checked and you pass zeros and an
empty proof. Tiers let you open the window in stages: a tier with a non-zero duration is active
only within its slice of time; a single tier with duration == 0 just opens at its start.
Read the tier config with getWhitelistTiers(fundManager).
Editing pool details
Besides the whitelist setters above, the owner (or a platform operator) can update a handful of other fields after creation:
| Function | Updates |
|---|---|
updatePoolDetails(fundManager, poolDetails) | free-form metadata; the owner can only call this while the pool is Active, an operator can call it any time |
setAffiliateBps(fundManager, affiliateBps) | affiliate reward BPS; can only be raised, capped by the platform's maxAffiliateBps |
setRequireContributorValidation(fundManager, requireContributorValidation) | toggles whether contributions need the platform's signature gate |
setClaimTime(fundManager, claimTime) | when claiming opens |
setTime(fundManager, startTime, endTime) | the sale window; only before the current startTime has passed |
setEndTime(fundManager, endTime) | shortens endTime; it cannot be extended |
setMaxContribution(fundManager, maxContribution) | per-wallet cap; must stay above minContribution |
Each of these emits PoolUpdated(index, fundManager, timestamp).
Contributing
Which function to call depends on the pool's currency. Read pool.currency: address(0) is native,
anything else is an ERC20.
Native pools:
function contribute(
address fundManager,
uint256 index, // your whitelist leaf index (0 in the public phase)
uint256 tier, // whitelist tier (0 in the public phase)
uint256 allocation, // whitelisted allocation (0 in the public phase)
bytes32[] proof, // Merkle proof (empty in the public phase)
address referrer, // affiliate referrer, or the zero address
bytes signature // platform authorization, or empty (see below)
) external payable; // contribution amount = msg.value
ERC20 pools use contributeCustomCurrency(...) with the same arguments plus an explicit amount,
and no msg.value. Approve the launchpad for amount first.
// native, public phase, no referrer, no signature gate
await launchpadRW.contribute(
fundManager, 0, 0, 0, [], ethers.ZeroAddress, "0x",
{ value: ethers.parseEther("1") }
);
// erc20 pool
const quote = new ethers.Contract(pool.currency, ERC20_ABI, signer);
await quote.approve(LAUNCHPAD, amount);
await launchpadRW.contributeCustomCurrency(
fundManager, 0, 0, 0, amount, [], ethers.ZeroAddress, "0x"
);
Two things to watch:
- Signature gate. If the pool was created with
requireContributorValidation, each contribution needs a platform-signed authorization insignature(an EIP-191 signature by the platform verification wallet overkeccak256(abi.encodePacked(fundManager, user, amount))). Your app requests it from the platform before contributing. If the pool does not require it, pass0x. - Referrer. Pass a referrer address to credit an affiliate on Presale and Fair Launch pools
(when the pool set
affiliateBps). Pass the zero address to skip it.
Common reverts: Amount cant be 0, Pool is either completed or cancelled, Contribution outside of start and end time, and the per-type caps (Buying amount exceeds hard cap, Contribute more than allowed, Min contribution not reached).
Every contribution emits Contributed(index, fundManager, user, amount, timestamp).
Finalizing
The owner finalizes when the finish conditions are met. Finalizing takes the platform fee, adds liquidity, and locks or burns the LP. Each type has its own call and its own condition.
| Type | Call | Finalizes when |
|---|---|---|
| Presale | finalizePresale(fundManager) | hard cap hit, or the gap left is below minContribution, or soft cap met and the sale ended |
| Fair Launch | finalizeFairLaunch(fundManager) | soft cap met and the sale ended |
| Subscription | finalizeSubscription(fundManager) | allocation approved and totalVolumePurchased >= softCapTokens |
await launchpadRW.finalizePresale(fundManager); // owner
All three emit Finalized(index, fundManager, timestamp). Fair Launch computes the final rate at
this point from the raise and the fixed supply, so read the pool back afterward to get it.
Claiming and vesting
After finalize, contributors claim. The right call depends on the pool type, because Subscription and overflow pools settle refunds with a Merkle proof.
// Presale and Fair Launch
await launchpadRW.claim(fundManager);
// Subscription: claim your allocation and get the excess refunded, with your proof
await launchpadRW.claimAndRefundWithProof(fundManager, index, purchased, proof);
Calling the wrong one reverts with a message telling you which to use.
Vesting
If the pool set tgePercent, cyclePercent, and cycle, tokens unlock over time instead of all at
once. At claim start a wallet can take tgePercent of its purchase, then another cyclePercent
every cycle seconds, up to the full amount. Read how much is claimable right now with:
const claimable = await launchpad.claimableVestingTokens(fundManager, user);
claim sends whatever is currently unlocked, so a vesting contributor calls it repeatedly as more
unlocks. With no vesting params set, the whole allocation is claimable at once.
Every claim emits Claimed(index, fundManager, user, claimed, totalPurchased, timestamp).
The owner (or an operator) can claim on behalf of a batch of contributors instead of waiting for each
to call claim themselves, for any finalized non-Subscription pool:
await launchpadRW.distributeTokens(fundManager, startIndex, endIndex); // owner or operator
Refunds and exits
Failed or cancelled pool
withdraw(fundManager) returns a contribution in full when the pool failed (ended below soft cap) or
was cancelled.
await launchpadRW.withdraw(fundManager);
For a live Presale or Fair Launch, this is allowed only after endTime when the soft cap was missed.
For a Subscription, only after cancellation. There is no fee.
Early exit while live
emergencyWithdraw(fundManager) leaves a live Presale or Fair Launch for a 10% penalty, blocked in
the last 15 minutes before endTime. Subscription pools do not support it.
await launchpadRW.emergencyWithdraw(fundManager);
Cancelling
The owner cancels an Active pool, which opens full withdrawals for contributors. Afterward the
owner reclaims the escrowed tokens with withdrawCancelledTokens(fundManager).
await launchpadRW.cancel(fundManager);
await launchpadRW.withdrawCancelledTokens(fundManager); // returns the pool's tokens to the owner
Cancel emits Cancelled(index, fundManager, timestamp); each refund emits
WithdrawContribution(index, fundManager, user, amount, timestamp).
The owner (or an operator) can also refund a batch of contributors directly instead of waiting for
each to call withdraw themselves, for a Cancelled Presale, Fair Launch, or Subscription pool (not
the Overflow variants):
await launchpadRW.distributeRefund(fundManager, startIndex, endIndex); // owner or operator
Subscription allocation
A Subscription pool needs one extra step between the sale ending and contributors claiming: the allocation. Because oversubscription means not everyone gets their full commitment, the split is computed off-chain by the platform (admin) and published on-chain as a Merkle root.
The lifecycle, and where each actor fits:
- The sale ends. Commitments are locked in.
- Admin computes the allocation and calls
submitAllocation(fundManager, allocationRoot, totalVolumePurchased). The calc stage moves toneedConfirm. - Admin calls
approveAllocation(fundManager). The stage moves tocalculated. - Anyone can now
finalizeSubscription(fundManager)(the owner usually does). - Contributors call
claimAndRefundWithProof(fundManager, index, purchased, proof)to take their allocation and get the rest refunded, orrefundExcessiveWithProof(...)to only take the refund.
You do not run steps 2 and 3; the platform does. What your app needs is the per-user purchased
amount and proof, which the platform publishes alongside the root. Track the stage yourself with:
const stage = await launchpad.getSubscriptionCalculationStage(fundManager);
// 0 notCalculated, 1 needConfirm, 2 calculated
The subscription leaf encoding is keccak256(bytes.concat(keccak256(abi.encode(index, user, purchased)))). Watch AllocationSubmitted and AllocationApproved to know when claiming opens.
Reading pool state
All reads are open to anyone.
| Function | Returns |
|---|---|
getPoolByIndex(index) | the Pool at a creation index |
getPoolByFundManager(fundManager) | the Pool for a handle |
getPoolsByIndexes(indexes[]) | batch by index |
getPoolsByFundManagers(fundManagers[]) | batch by handle |
getPoolCount() | total pools ever created |
getContributionInfo(fundManager, user) | one contributor's ledger |
getContributorCountByFundManager(fundManager) | contributor count |
getContributorCountByPoolIndex(index) | contributor count, by creation index |
getContributionInfosByFundManager(fundManager, start, end) | a page of contributors |
getWhitelistTiers(fundManager) | the whitelist tiers |
excessiveCommitment(fundManager, user, purchased) | Subscription only: how much of a wallet's commitment is refundable at a given allocation; 0 for other pool types |
One caveat worth coding around: getPoolByFundManager does not verify the address belongs to a real
pool. An unknown address maps to pool index 0 and returns that pool. Confirm the result by checking
returnedPool.fundManager === fundManager before you trust it.
The full Pool struct is in events-and-data.md.
Error reference
Create
| Message | Cause |
|---|---|
Invalid fee amount | msg.value is not exactly the pool creation fee |
Invalid router / Cannot set router for manual listing pool | router does not match the listing mode |
Invalid currency | currency is not native and not a supported ERC20 |
Invalid contribution settings | minContribution > maxContribution |
Invalid cap settings | presale caps break softCap * 4 >= hardCap and softCap < hardCap |
Invalid time settings / Both time has to be unset | bad start/end pair |
Invalid listing percent | listingPercent outside 20 to 100 |
Invalid total selling tokens | fair launch with totalSellingTokens == 0 |
Not enough balance / Not enough allowance | you did not fund or approve the token total |
Transfer not enough token to pool | fee-on-transfer or rebasing token |
Contribute
| Message | Cause |
|---|---|
Invalid currency | using the native call on an ERC20 pool, or vice versa |
Amount cant be 0 | zero contribution |
Pool is either completed or cancelled | pool not active |
Contribution outside of start and end time | before start or after end |
Buying amount exceeds hard cap / Contribute more than allowed / Min contribution not reached | per-type caps |
Invalid signature | pool requires a platform authorization and it was missing or wrong |
WhitelistManager: Invalid proof | wrong Merkle proof during the whitelist window |
Finalize / claim / withdraw
| Message | Cause |
|---|---|
Unauthorized | not the pool owner |
It is not time to finish | finalize conditions not yet met |
Pool is not finalized | claim before finalize |
Invalid proof | wrong proof for an overflow or subscription claim/refund |
Soft cap reached | full withdraw attempted on a pool that met soft cap |
Pool is still in progress | full withdraw before the sale ended |
It is too late to do emergency withdraw contribution | inside the last 15 minutes |