DroppieDrop
DroppieDrop is the escrow for a single airdrop campaign. Each campaign is a plain contract deployed directly by the creator's wallet — no factory, no proxy. It holds the campaign's tokens, verifies Merkle proofs, and pays out claims.
Constructor
constructor(
IERC20 token_,
bytes32 merkleRoot_,
uint64 startTime_, // 0 = immediate
uint64 endTime_, // 0 = no deadline
string memory metadataURI_ // distribution JSON (ipfs://, https://, data:)
)
Deploys and configures the campaign in one step. The wallet that sends the deployment transaction becomes creator — the only address allowed to sweep. All parameters are fixed at deployment: token, merkleRoot, startTime, and endTime are Solidity immutables, and metadataURI has no setter.
| Parameter | Description |
|---|---|
token_ | The ERC-20 being distributed. |
merkleRoot_ | Root of the recipients tree (leaf scheme). |
startTime_ | Unix timestamp claims open. 0 means claims open immediately. |
endTime_ | Unix timestamp claims close. 0 means no deadline. |
metadataURI_ | Where the distribution JSON lives — ipfs://, https://, or data: URI. |
Reverts with:
| Error | Condition |
|---|---|
ZeroAddress | token_ is the zero address |
ZeroMerkleRoot | merkleRoot_ is bytes32(0) |
InvalidTimeWindow | endTime_ != 0 and it is not strictly after both startTime_ and the current block timestamp |
Funding
A freshly deployed drop holds zero tokens. Funding is a second, separate transaction — a plain ERC-20 transfer from the creator's wallet:
token.transfer(drop, totalAmount);
No approve, no transferFrom, no special function on the drop. Anyone can top a drop up the same way.
Underfunded behavior: a claim against a drop whose balance can't cover it reverts inside the token's own transfer (for OpenZeppelin tokens, ERC20InsufficientBalance) — the whole transaction unwinds, so no claim state is consumed and the recipient can simply claim again once the drop is funded. The app additionally balance-gates claims and shows such drops as underfunded, so recipients don't waste gas.
Functions
claim
function claim(address account, uint256 amount, bytes32[] calldata proof) external;
Pays out one recipient's allocation. Verifies that (account, amount) is a leaf of merkleRoot using the supplied proof, marks the account claimed, and transfers amount tokens (via SafeERC20.safeTransfer) to account. Emits Claimed.
Reverts with:
| Error | Condition |
|---|---|
AlreadyClaimed | hasClaimed[account] is already true |
InvalidProof | Proof does not verify against merkleRoot for (account, amount) |
DropNotStarted | startTime is set and hasn't been reached |
DropEnded | endTime is set and has passed |
| (token's own error) | The drop is underfunded — the inner transfer reverts and no state changes |
Note that claim is permissionless: anyone may submit the transaction (paying the gas), but tokens are always sent to account — the proven recipient — never to msg.sender. Sponsoring a claim for someone else is safe by construction.
sweep
function sweep(address to) external;
Transfers the drop's entire remaining token balance to to. Restricted to the campaign creator (reverts NotCreator otherwise; ZeroAddress if to is zero). Emits Swept.
Timing rules — read carefully:
- If
endTimeis set (non-zero): sweeping is only possible strictly afterendTime(revertsDropNotEndedbefore that). Recipients have the full claim window guaranteed; the creator cannot pull funds out from under an active drop. - If
endTime == 0(no deadline): the drop never expires, so the creator may sweep at any time. This is the escape hatch for open-ended drops — otherwise unclaimed funds would be locked forever — but it means a deadline-less drop's funds can be withdrawn by the creator while claims are live. Recipients of high-value, deadline-less drops should claim promptly.
After a sweep, remaining claims will fail with insufficient balance even though their proofs remain valid.
View functions
| Function | Returns | Description |
|---|---|---|
hasClaimed(address) | bool | Whether the address has already claimed |
creator() | address | Campaign owner — the deploying wallet (sweep authority) |
token() | IERC20 | Token being distributed |
merkleRoot() | bytes32 | The recipients tree root |
startTime() | uint64 | Claim window opens (0 = immediate) |
endTime() | uint64 | Claim window closes (0 = no deadline) |
metadataURI() | string | Location of the distribution JSON |
totalClaimed() | uint256 | Running sum of all claimed amounts (raw wei) |
claimCount() | uint256 | Number of successful claims so far |
totalClaimed and claimCount power the progress bars and charts in the app — no indexer required.
Events
Claimed
event Claimed(address indexed account, uint256 amount);
Emitted on every successful claim. The app builds its claims-over-time chart and activity feed directly from these logs.
Swept
event Swept(address indexed to, uint256 amount);
Emitted when the creator sweeps remaining funds; amount is the balance transferred.
Errors
error AlreadyClaimed();
error InvalidProof();
error DropNotStarted();
error DropEnded();
error DropNotEnded();
error NotCreator();
error ZeroAddress();
error ZeroMerkleRoot();
error InvalidTimeWindow();
(Token transfers go through OpenZeppelin SafeERC20, so a misbehaving token can also surface as SafeERC20FailedOperation.)
Deployment example
Deploying and funding a drop with viem and the ABI + bytecode from @droppie/sdk — exactly what the app's create wizard does:
import { droppieDropAbi, droppieDropBytecode, erc20Abi } from "@droppie/sdk";
// tx #1 — deploy; the sending wallet becomes creator
const hash = await walletClient.deployContract({
abi: droppieDropAbi,
bytecode: droppieDropBytecode,
args: [token, merkleRoot, 0n, 0n, metadataURI],
});
const { contractAddress: drop } = await publicClient.waitForTransactionReceipt({
hash,
});
// tx #2 — fund; one plain transfer, no approve
await walletClient.writeContract({
address: token,
abi: erc20Abi,
functionName: "transfer",
args: [drop!, totalAmount],
});
Claim example
Reading drop state and claiming:
import { droppieDropAbi } from "@droppie/sdk";
const [root, claimed] = await Promise.all([
publicClient.readContract({
address: drop,
abi: droppieDropAbi,
functionName: "merkleRoot",
}),
publicClient.readContract({
address: drop,
abi: droppieDropAbi,
functionName: "hasClaimed",
args: [account],
}),
]);
if (!claimed) {
await walletClient.writeContract({
address: drop,
abi: droppieDropAbi,
functionName: "claim",
args: [account, amount, proof],
});
}
See the SDK reference for generating proof and amount.