Skip to main content

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.

ParameterDescription
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:

ErrorCondition
ZeroAddresstoken_ is the zero address
ZeroMerkleRootmerkleRoot_ is bytes32(0)
InvalidTimeWindowendTime_ != 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:

ErrorCondition
AlreadyClaimedhasClaimed[account] is already true
InvalidProofProof does not verify against merkleRoot for (account, amount)
DropNotStartedstartTime is set and hasn't been reached
DropEndedendTime 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 endTime is set (non-zero): sweeping is only possible strictly after endTime (reverts DropNotEnded before 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

FunctionReturnsDescription
hasClaimed(address)boolWhether the address has already claimed
creator()addressCampaign owner — the deploying wallet (sweep authority)
token()IERC20Token being distributed
merkleRoot()bytes32The recipients tree root
startTime()uint64Claim window opens (0 = immediate)
endTime()uint64Claim window closes (0 = no deadline)
metadataURI()stringLocation of the distribution JSON
totalClaimed()uint256Running sum of all claimed amounts (raw wei)
claimCount()uint256Number 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.