Implement Lockdrops
Build a locked airdrop in your own app. Set the allocations, fund a campaign and let each recipient accept a lock they own.
Not independently audited. Independent security audits are planned. Read the security model
- 01 / Creator
Fund the campaign
Approve the module.
2% creator fee
Deposit the gross amount. - 02 / Module
Reserve allocations
Hold the recipient budget.
Immutable campaign terms
Verify each accepted claim. - 03 / Vault
Create the user’s lock
The recipient opts in.
No early exit
Their principal stays until maturity.
Permissionless integration. No registration, API key or integration fee. Creators pay a 2% funding fee, rounded down to raw token units. Recipients pay no protocol fee; network gas is separate.
Explore proofs, funding and claims in the interactive examples below. Each starts with its own sample data and runs locally without a wallet.
Choose how recipients qualify
Both campaign types create the same recipient-owned lock. Choose the allocation model that fits your product.
You choose the allocations.
Publish a Merkle root for a fixed list of wallets and amounts. Each recipient accepts their allocation with a proof.
createCampaign / claimStart with explicit allocations Existing lockersYou choose the conditions.
Reward existing vault positions holding a chosen token. The contract checks each position and calculates its fixed share.
createLockerCampaign / claimLockerCampaignUse onchain eligibility The walkthrough starts with a Merkle airdrop. A recipient does not need an existing lock or a reward-token balance. Their connected wallet must match the allocation and hold the chain’s native token for gas.
Start with the implementation kit
Use TypeScript and viem in your own app. These files contain the ABI, allocation builder, transaction helpers and a local lifecycle demo.
Create a project
Use Node.js 24.x or Node.js 22.12+ within the 22.x release. Save the files below in the same folder so their relative imports resolve.
Install the Lockdrop example dependencies mkdir conviction-lockdrop cd conviction-lockdrop npm init -y npm pkg set type=module npm install viem@2 npm install --save-dev tsxDownload the source files
Each tab is a complete file with copy and download controls. Start with the allocation builder, then connect the transaction helpers to your verified deployment and wallet.
ABI
The module, vault and token interfaces used by these examples. Keep these paired with the deployed contract version.
abi.ts import { erc20Abi, parseAbi } from "viem"; // Minimal, standalone ABI fragments for the functions used in these examples. export { erc20Abi }; export const lockdropsAbi = parseAbi([ "function vault() view returns (address)", "function quoteFunding(uint256 grossAmount) pure returns (uint256 fee, uint256 budget)", "function createCampaign((address token, uint256 grossAmount, bytes32 merkleRoot, uint40 claimStart, uint40 claimDeadline, uint40 lockDuration, uint256 minNetBudget) p) returns (uint256 campaignId)", "function createLockerCampaign((address token, uint256 grossAmount, address eligibleToken, uint256 minPositionAmount, uint40 minRemainingDuration, uint40 claimStart, uint40 claimDeadline, uint40 lockDuration, uint256 minNetBudget, uint256 minPrincipalBasis, uint256 maxPrincipalBasis) p) returns (uint256 campaignId)", "function getCampaign(uint256 campaignId) view returns ((address creator, address token, bytes32 merkleRoot, uint40 claimStart, uint40 claimDeadline, uint40 lockDuration, uint256 budget, uint256 claimed, uint256 refunded))", "function getLockRule(uint256 campaignId) view returns ((address eligibleToken, uint256 minPositionAmount, uint40 minRemainingDuration, uint256 positionIdLimit, uint256 principalBasis))", "function previewLockerClaim(uint256 campaignId, uint256 sourceId) view returns (uint256 amount, uint8 status)", "function isClaimed(uint256 campaignId, uint256 index) view returns (bool)", "function claim(uint256 campaignId, uint256 index, address recipient, uint256 amount, bytes32[] proof) returns (uint256 positionId)", "function claimLockerCampaign(uint256 campaignId, uint256[] sourceIds) returns (uint256 positionId)", "function refund(uint256 campaignId) returns (uint256 amount)", "event CampaignCreated(uint256 indexed campaignId, address indexed creator, address indexed token, bytes32 merkleRoot, uint256 grossAmount, uint256 fee, uint256 budget, uint40 claimStart, uint40 claimDeadline, uint40 lockDuration)", "event Claimed(uint256 indexed campaignId, uint256 indexed index, address indexed recipient, uint256 amount, uint256 positionId)", "event Refunded(uint256 indexed campaignId, address indexed creator, uint256 amount)", ]); export const vaultAbi = parseAbi([ "function terms() view returns (uint40 minDuration, uint40 maxDuration, uint16 maxPenaltyBps, uint16 minPenaltyBps)", "function locksPaused() view returns (bool)", "function totalLocked(address token) view returns (uint256)", "function getPosition(uint256 id) view returns ((address owner, uint40 lockedAt, uint40 unlockAt, uint8 status, address token, uint16 maxPenaltyBps, uint16 minPenaltyBps, bool noEarlyExit, uint256 amount))", "function release(uint256 id)", ]);Allocations
Build and validate indexed allocations, standard double-hashed leaves, sorted-pair proofs and the integer funding quote.
merkle.ts import { concat, encodeAbiParameters, getAddress, keccak256, maxUint256, zeroAddress, type Address, type Hex } from "viem"; export type Recipient = { recipient: Address; amount: bigint }; // Decimal strings make allocation files JSON-safe without losing integer precision. export type Allocation = { index: string; recipient: Address; amount: string; proof: Hex[] }; export type AllocationTree = { merkleRoot: Hex; budget: bigint; allocations: Allocation[] }; export function hashLeaf(index: bigint, recipient: Address, amount: bigint): Hex { return keccak256(keccak256(encodeAbiParameters( [{ type: "uint256" }, { type: "address" }, { type: "uint256" }], [index, recipient, amount], ))); } function pair(left: Hex, right: Hex): Hex { left = left.toLowerCase() as Hex; right = right.toLowerCase() as Hex; return keccak256(concat(left < right ? [left, right] : [right, left])); } // Matches OpenZeppelin StandardMerkleTree: sorted double-hashed leaves, // a complete binary tree, and sorted pairs. Allocation indexes retain input order. export function buildAllocations(recipients: readonly Recipient[]): AllocationTree { if (!recipients.length || recipients.length > 2_000) throw new Error("Use 1 to 2,000 recipients in this example."); let budget = 0n; const seen = new Set<string>(); const allocations = recipients.map(({ recipient, amount }, index): Allocation => { const normalized = getAddress(recipient); if (normalized === zeroAddress || seen.has(normalized)) throw new Error("Use unique, nonzero recipients."); if (amount <= 0n || amount > maxUint256) throw new Error("Use a positive uint256 allocation."); seen.add(normalized); budget += amount; if (budget > maxUint256) throw new Error("Allocation total exceeds uint256."); return { index: String(index), recipient: normalized, amount: amount.toString(), proof: [] }; }); const leaves = allocations.map((value, index) => ({ index, hash: hashLeaf(BigInt(value.index), value.recipient, BigInt(value.amount)) })) .sort((a, b) => a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0); const tree = new Array<Hex>(2 * leaves.length - 1); leaves.forEach((leaf, index) => { tree[tree.length - 1 - index] = leaf.hash; }); for (let index = leaves.length - 2; index >= 0; index--) tree[index] = pair(tree[2 * index + 1], tree[2 * index + 2]); leaves.forEach((leaf, index) => { for (let at = tree.length - 1 - index; at > 0; at = Math.floor((at - 1) / 2)) { allocations[leaf.index].proof.push(tree[at % 2 === 0 ? at - 1 : at + 1]); } }); return { merkleRoot: tree[0], budget, allocations }; } export function verifyAllocation(root: Hex, allocation: Allocation): boolean { if (!/^(0|[1-9][0-9]*)$/.test(allocation.index) || !/^[1-9][0-9]*$/.test(allocation.amount) || !/^0x[0-9a-fA-F]{64}$/.test(root) || allocation.proof.length > 32 || allocation.proof.some(value => !/^0x[0-9a-fA-F]{64}$/.test(value))) return false; try { if (getAddress(allocation.recipient) === zeroAddress) return false; const value = allocation.proof.reduce(pair, hashLeaf(BigInt(allocation.index), allocation.recipient, BigInt(allocation.amount))); return value.toLowerCase() === root.toLowerCase(); } catch { return false; } } // Smallest gross amount whose floor-rounded 2% fee leaves this exact budget. export function fundingForBudget(budget: bigint) { if (budget < 49n || budget > maxUint256) throw new Error("Budget must be at least 49 raw units and fit uint256."); const candidate = budget + (budget - 1n) / 49n; const grossAmount = candidate < 50n ? 50n : candidate; if (grossAmount > maxUint256) throw new Error("Gross funding exceeds uint256."); return { grossAmount, fee: grossAmount / 50n, budget }; }Client
Deployment checks and wallet transactions. Connect this to your verified chain, module and vault addresses.
client.ts import { getAddress, type Abi, type Address, type Hex, type PublicClient, type TransactionReceipt, type WalletClient } from "viem"; import { erc20Abi, lockdropsAbi, vaultAbi } from "./abi.ts"; export type Session = { publicClient: PublicClient; walletClient: WalletClient; account: Address; deployment: { chainId: number; module: Address; vault: Address }; // Persist each submitted hash before waiting. See the guide for retry/replacement recovery. onSubmitted?: (hash: Hex) => void | Promise<void>; }; export function sameAddress(left: Address, right: Address) { return getAddress(left) === getAddress(right); } export async function assertSession(session: Session) { const { publicClient, walletClient, account, deployment } = session; const [readChain, walletChain, accounts] = await Promise.all([ publicClient.getChainId(), walletClient.getChainId(), walletClient.getAddresses(), ]); if (readChain !== deployment.chainId || walletChain !== deployment.chainId) throw new Error("Switch the wallet and RPC to the expected chain."); if (!accounts[0] || !sameAddress(accounts[0], account)) throw new Error("The selected wallet account changed."); if (walletClient.account && !sameAddress(walletClient.account.address, account)) throw new Error("Wallet signer does not match the expected account."); const configuredVault = await publicClient.readContract({ address: deployment.module, abi: lockdropsAbi, functionName: "vault" }); if (!sameAddress(configuredVault, deployment.vault)) throw new Error("Lockdrop module points to a different vault."); } export async function write(session: Session, address: Address, abi: Abi, functionName: string, args: readonly unknown[] = []) { await assertSession(session); const { request } = await session.publicClient.simulateContract({ address, abi, functionName, args, account: session.account, }); // Check again after simulation in case the wallet changed accounts/networks. await assertSession(session); const hash = await session.walletClient.writeContract({ ...request, account: session.walletClient.account ?? session.account, chain: session.walletClient.chain }); await session.onSubmitted?.(hash); let changedReplacement = false; const receipt = await session.publicClient.waitForTransactionReceipt({ hash, onReplaced: ({ reason }) => { if (reason !== "repriced") changedReplacement = true; }, }); if (changedReplacement) throw new Error("Transaction was replaced or cancelled. Reconcile its receipt before retrying."); if (receipt.status !== "success") throw new Error(`Transaction reverted: ${receipt.transactionHash}`); return receipt; } export function moduleLogs(session: Session, receipt: TransactionReceipt) { return receipt.logs.filter(log => sameAddress(log.address, session.deployment.module)); } // The creator approves the module, never the vault. Recipients need no token approval. // Reset first to support tokens that require a zero allowance before a new approval. export async function approveFunding(session: Session, token: Address, amount: bigint) { await assertSession(session); const balance = await session.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [session.account] }); if (balance < amount) throw new Error("Creator has insufficient funding tokens."); const allowance = await session.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "allowance", args: [session.account, session.deployment.module] }); if (allowance >= amount) return; if (allowance !== 0n) await write(session, token, erc20Abi, "approve", [session.deployment.module, 0n]); await write(session, token, erc20Abi, "approve", [session.deployment.module, amount]); } export async function validateTerms(session: Session, claimStart: number, claimDeadline: number, lockDuration: number) { await assertSession(session); const [block, bounds, paused] = await Promise.all([ session.publicClient.getBlock(), session.publicClient.readContract({ address: session.deployment.vault, abi: vaultAbi, functionName: "terms" }), session.publicClient.readContract({ address: session.deployment.vault, abi: vaultAbi, functionName: "locksPaused" }), ]); if (![claimStart, claimDeadline, lockDuration].every(Number.isSafeInteger) || claimStart < 0 || claimStart >= claimDeadline || BigInt(claimDeadline) <= block.timestamp || BigInt(claimDeadline) > block.timestamp + 365n * 86_400n || lockDuration < bounds[0] || lockDuration > bounds[1] || claimDeadline + lockDuration > 2 ** 40 - 1) { throw new Error("Campaign window or duration is outside the current contract bounds."); } if (paused) throw new Error("The vault currently pauses new locks."); } // Read the actual vault position; event IDs and simulation return values are not lock terms. export async function verifyRewardPosition(session: Session, positionId: bigint, token: Address, amount: bigint, duration: number) { const position = await session.publicClient.readContract({ address: session.deployment.vault, abi: vaultAbi, functionName: "getPosition", args: [positionId], }); if (!sameAddress(position.owner, session.account) || !sameAddress(position.token, token) || position.amount !== amount || !position.noEarlyExit || position.status !== 0 || position.unlockAt - position.lockedAt !== duration) throw new Error("Confirmed reward lock differs from the accepted terms."); return position; }Campaign lifecycle
Approve and fund a Merkle campaign, accept an allocation, refund unused funding and release matured principal.
campaigns.ts import { parseEventLogs, type Address, type Hex } from "viem"; import { lockdropsAbi, vaultAbi } from "./abi.ts"; import { approveFunding, assertSession, moduleLogs, sameAddress, validateTerms, verifyRewardPosition, write, type Session } from "./client.ts"; import { fundingForBudget, verifyAllocation, type Allocation, type AllocationTree } from "./merkle.ts"; export type CampaignFile = { chainId: number; module: Address; vault: Address; creator: Address; token: Address; campaignId: string | null; merkleRoot: Hex; claimStart: number; claimDeadline: number; lockDuration: number; grossAmount: string; fee: string; budget: string; allocations: Allocation[]; }; export type MerkleCampaignInput = { token: Address; tree: AllocationTree; claimStart: number; claimDeadline: number; lockDuration: number; }; export async function createMerkleCampaign( session: Session, input: MerkleCampaignInput, saveDraft: (file: CampaignFile) => void | Promise<void>, ): Promise<CampaignFile> { await validateTerms(session, input.claimStart, input.claimDeadline, input.lockDuration); const { tree } = input; const indexes = new Set(tree.allocations.map(value => value.index)); if (!tree.allocations.length || indexes.size !== tree.allocations.length || tree.allocations.some(value => BigInt(value.amount) <= 0n || !verifyAllocation(tree.merkleRoot, value)) || tree.allocations.reduce((total, value) => total + BigInt(value.amount), 0n) !== tree.budget) { throw new Error("The allocation tree does not account for its full budget."); } const funding = fundingForBudget(tree.budget); const [fee, budget] = await session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "quoteFunding", args: [funding.grossAmount], }); if (fee !== funding.fee || budget !== tree.budget) throw new Error("Onchain funding quote differs from this example."); const draft: CampaignFile = { ...session.deployment, creator: session.account, token: input.token, campaignId: null, merkleRoot: tree.merkleRoot, claimStart: input.claimStart, claimDeadline: input.claimDeadline, lockDuration: input.lockDuration, grossAmount: funding.grossAmount.toString(), fee: fee.toString(), budget: budget.toString(), allocations: tree.allocations, }; // Persist the complete file before any transaction. A root cannot reconstruct allocations. await saveDraft(draft); await approveFunding(session, input.token, funding.grossAmount); const params = { token: input.token, grossAmount: funding.grossAmount, merkleRoot: tree.merkleRoot, claimStart: input.claimStart, claimDeadline: input.claimDeadline, lockDuration: input.lockDuration, minNetBudget: budget, }; const receipt = await write(session, session.deployment.module, lockdropsAbi, "createCampaign", [params]); const event = parseEventLogs({ abi: lockdropsAbi, eventName: "CampaignCreated", logs: moduleLogs(session, receipt) }) .find(({ args }) => sameAddress(args.creator, session.account) && sameAddress(args.token, input.token) && args.merkleRoot === tree.merkleRoot && args.grossAmount === funding.grossAmount && args.fee === fee && args.budget === budget && args.claimStart === input.claimStart && args.claimDeadline === input.claimDeadline && args.lockDuration === input.lockDuration); if (!event) throw new Error("Receipt does not confirm this campaign. Recover the submitted transaction before retrying."); // The simulation's returned ID is only a prediction. The confirmed event is authoritative. return { ...draft, campaignId: event.args.campaignId.toString() }; } // Treat downloaded files as untrusted. Bind their identity and immutable terms to fresh reads. export async function readVerifiedCampaign(session: Session, file: CampaignFile) { await assertSession(session); if (file.chainId !== session.deployment.chainId || !sameAddress(file.module, session.deployment.module) || !sameAddress(file.vault, session.deployment.vault) || !file.campaignId || BigInt(file.campaignId) <= 0n) { throw new Error("Campaign file belongs to a different deployment or is still a draft."); } const campaign = await session.publicClient.readContract({ address: file.module, abi: lockdropsAbi, functionName: "getCampaign", args: [BigInt(file.campaignId)], }); const gross = BigInt(file.grossAmount); if (!sameAddress(campaign.creator, file.creator) || !sameAddress(campaign.token, file.token) || campaign.merkleRoot.toLowerCase() !== file.merkleRoot.toLowerCase() || campaign.claimStart !== file.claimStart || campaign.claimDeadline !== file.claimDeadline || campaign.lockDuration !== file.lockDuration || campaign.budget !== BigInt(file.budget) || gross < 50n || gross / 50n !== BigInt(file.fee) || gross - BigInt(file.fee) !== campaign.budget) throw new Error("Campaign file does not match onchain terms."); return campaign; } export async function claimAllocation(session: Session, file: CampaignFile, allocation: Allocation) { const campaign = await readVerifiedCampaign(session, file); if (!sameAddress(session.account, allocation.recipient)) throw new Error("Only the allocation recipient can accept the lock."); if (BigInt(allocation.amount) <= 0n || !verifyAllocation(campaign.merkleRoot, allocation)) throw new Error("Invalid allocation proof."); const block = await session.publicClient.getBlock(); if (block.timestamp < BigInt(campaign.claimStart) || block.timestamp >= BigInt(campaign.claimDeadline)) throw new Error("Claims are closed."); const campaignId = BigInt(file.campaignId!); const index = BigInt(allocation.index); if (await session.publicClient.readContract({ address: file.module, abi: lockdropsAbi, functionName: "isClaimed", args: [campaignId, index] })) { throw new Error("This allocation was already accepted."); } const receipt = await write(session, file.module, lockdropsAbi, "claim", [campaignId, index, session.account, BigInt(allocation.amount), allocation.proof]); const event = parseEventLogs({ abi: lockdropsAbi, eventName: "Claimed", logs: moduleLogs(session, receipt) }) .find(({ args }) => args.campaignId === campaignId && args.index === index && sameAddress(args.recipient, session.account) && args.amount === BigInt(allocation.amount)); if (!event) throw new Error("No matching claim event. Recover the transaction before retrying."); await verifyRewardPosition(session, event.args.positionId, campaign.token, BigInt(allocation.amount), campaign.lockDuration); return event.args.positionId; } // Usable with either campaign mode. Always read the actual campaign creator/window first. export async function refundCampaign(session: Session, campaignId: bigint) { await assertSession(session); const campaign = await session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "getCampaign", args: [campaignId] }); if (!sameAddress(campaign.creator, session.account)) throw new Error("Only the campaign creator can refund."); const block = await session.publicClient.getBlock(); if (block.timestamp < BigInt(campaign.claimDeadline)) throw new Error("Refunds open at the claim deadline."); const amount = campaign.budget - campaign.claimed - campaign.refunded; if (amount === 0n) throw new Error("There is no unused budget to refund."); const receipt = await write(session, session.deployment.module, lockdropsAbi, "refund", [campaignId]); const event = parseEventLogs({ abi: lockdropsAbi, eventName: "Refunded", logs: moduleLogs(session, receipt) }) .find(({ args }) => args.campaignId === campaignId && sameAddress(args.creator, session.account)); if (!event) throw new Error("No matching refund event."); return event.args.amount; } export async function releasePosition(session: Session, positionId: bigint) { await assertSession(session); const position = await session.publicClient.readContract({ address: session.deployment.vault, abi: vaultAbi, functionName: "getPosition", args: [positionId] }); if (!sameAddress(position.owner, session.account)) throw new Error("Select the reward position owner."); const block = await session.publicClient.getBlock(); if (position.status !== 0 || block.timestamp < BigInt(position.unlockAt)) throw new Error("Position is not active and mature."); return write(session, session.deployment.vault, vaultAbi, "release", [positionId]); }Existing lockers
Create a rule-based campaign and accept qualifying source positions in bounded batches.
existing-lockers.ts import { parseEventLogs, zeroHash, type Address } from "viem"; import { lockdropsAbi, vaultAbi } from "./abi.ts"; import { approveFunding, assertSession, moduleLogs, sameAddress, validateTerms, verifyRewardPosition, write, type Session } from "./client.ts"; export type LockerCampaignInput = { token: Address; grossAmount: bigint; eligibleToken: Address; minPositionAmount: bigint; minRemainingDuration: number; claimStart: number; claimDeadline: number; lockDuration: number; minNetBudget: bigint; minPrincipalBasis: bigint; maxPrincipalBasis: bigint; }; export async function createLockerCampaign(session: Session, input: LockerCampaignInput) { await validateTerms(session, input.claimStart, input.claimDeadline, input.lockDuration); const basis = await session.publicClient.readContract({ address: session.deployment.vault, abi: vaultAbi, functionName: "totalLocked", args: [input.eligibleToken] }); if (basis === 0n || basis < input.minPrincipalBasis || basis > input.maxPrincipalBasis) throw new Error("Current principal is outside the creator's accepted bounds."); const [fee, budget] = await session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "quoteFunding", args: [input.grossAmount] }); if (input.grossAmount < 50n || budget < input.minNetBudget) throw new Error("Insufficient net campaign budget."); await approveFunding(session, input.token, input.grossAmount); const receipt = await write(session, session.deployment.module, lockdropsAbi, "createLockerCampaign", [input]); const event = parseEventLogs({ abi: lockdropsAbi, eventName: "CampaignCreated", logs: moduleLogs(session, receipt) }) .find(({ args }) => sameAddress(args.creator, session.account) && sameAddress(args.token, input.token) && args.merkleRoot === zeroHash && args.grossAmount === input.grossAmount && args.fee === fee && args.budget === budget && args.claimStart === input.claimStart && args.claimDeadline === input.claimDeadline && args.lockDuration === input.lockDuration); if (!event) throw new Error("No matching campaign event. Recover the transaction before retrying."); const campaignId = event.args.campaignId; const rule = await session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "getLockRule", args: [campaignId] }); if (!sameAddress(rule.eligibleToken, input.eligibleToken) || rule.minPositionAmount !== input.minPositionAmount || rule.minRemainingDuration !== input.minRemainingDuration || rule.principalBasis < input.minPrincipalBasis || rule.principalBasis > input.maxPrincipalBasis) throw new Error("Confirmed campaign rules differ from the accepted rules."); return { campaignId, rule, budget }; } export const lockerStatuses = ["Ready", "WrongToken", "TooNew", "Closed", "TooSmall", "TooShort", "AlreadyClaimed", "ZeroReward"] as const; export async function previewLockerSources(session: Session, campaignId: bigint, sourceIds: readonly bigint[]) { await assertSession(session); const [campaign, block] = await Promise.all([ session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "getCampaign", args: [campaignId] }), session.publicClient.getBlock(), ]); const windowOpen = block.timestamp >= BigInt(campaign.claimStart) && block.timestamp < BigInt(campaign.claimDeadline); return Promise.all(sourceIds.map(async sourceId => { const [[amount, code], source] = await Promise.all([ session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "previewLockerClaim", args: [campaignId, sourceId] }), session.publicClient.readContract({ address: session.deployment.vault, abi: vaultAbi, functionName: "getPosition", args: [sourceId] }), ]); const owned = sameAddress(source.owner, session.account); // Ready does not check ownership, the claim window, or current vault availability. return { sourceId, amount, status: lockerStatuses[code], owned, windowOpen, canSubmit: code === 0 && owned && windowOpen }; })); } export async function claimLockerSources(session: Session, campaignId: bigint, sourceIds: readonly bigint[]) { const ids = [...sourceIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); if (!ids.length || ids.length > 32 || new Set(ids).size !== ids.length) throw new Error("Provide 1 to 32 distinct source IDs."); const previews = await previewLockerSources(session, campaignId, ids); if (previews.some(value => !value.canSubmit)) throw new Error("A source is not eligible, not owned, or outside the claim window."); const receipt = await write(session, session.deployment.module, lockdropsAbi, "claimLockerCampaign", [campaignId, ids]); const claims = parseEventLogs({ abi: lockdropsAbi, eventName: "Claimed", logs: moduleLogs(session, receipt) }) .filter(({ args }) => args.campaignId === campaignId && sameAddress(args.recipient, session.account) && ids.includes(args.index)); if (claims.length !== ids.length || new Set(claims.map(value => value.args.index)).size !== ids.length || new Set(claims.map(value => value.args.positionId)).size !== 1) throw new Error("Receipt does not confirm every source claim."); const amount = claims.reduce((total, value) => total + value.args.amount, 0n); const campaign = await session.publicClient.readContract({ address: session.deployment.module, abi: lockdropsAbi, functionName: "getCampaign", args: [campaignId] }); await verifyRewardPosition(session, claims[0].args.positionId, campaign.token, amount, campaign.lockDuration); return { positionId: claims[0].args.positionId, amount }; }Local demo
Exercise the lifecycle on a local Anvil chain with compiled Conviction contracts. See its prerequisites before running it.
local-demo.ts // Run inside a clone after `cd contracts && forge build`, with local Anvil running. // From app/: node --import tsx src/docs/examples/lockdrops/local-demo.ts // These test accounts are public. The script refuses non-loopback RPCs and non-31337 chains. import assert from "node:assert/strict"; import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPublicClient, createWalletClient, http, parseEventLogs, type Abi, type Hex } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { foundry } from "viem/chains"; import { erc20Abi, vaultAbi } from "./abi.ts"; import { write, type Session } from "./client.ts"; import { claimAllocation, createMerkleCampaign, refundCampaign, releasePosition } from "./campaigns.ts"; import { claimLockerSources, createLockerCampaign, previewLockerSources } from "./existing-lockers.ts"; import { buildAllocations, fundingForBudget } from "./merkle.ts"; const rpcUrl = process.env.LOCKDROP_RPC_URL ?? "http://127.0.0.1:8545"; const url = new URL(rpcUrl); if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.protocol !== "http:") { throw new Error("This demonstration only accepts a local HTTP Anvil RPC."); } const publicClient = createPublicClient({ chain: foundry, transport: http(rpcUrl), pollingInterval: 50 }); if (await publicClient.getChainId() !== 31337) throw new Error("Expected local Anvil chain 31337."); const mnemonic = "test test test test test test test test test test test junk"; const creatorAccount = mnemonicToAccount(mnemonic, { addressIndex: 0 }); const recipientAccount = mnemonicToAccount(mnemonic, { addressIndex: 1 }); const unclaimedAccount = mnemonicToAccount(mnemonic, { addressIndex: 2 }); const creatorWallet = createWalletClient({ account: creatorAccount, chain: foundry, transport: http(rpcUrl) }); const recipientWallet = createWalletClient({ account: recipientAccount, chain: foundry, transport: http(rpcUrl) }); async function artifact(path: string): Promise<{ abi: Abi; bytecode: { object: Hex } }> { return JSON.parse(await readFile(new URL(`../../../../../contracts/out/${path}`, import.meta.url), "utf8")); } const conviction = await artifact("Conviction.sol/Conviction.json"); const vaultArtifact = await artifact("ConvictionVault.sol/ConvictionVault.json"); const moduleArtifact = await artifact("ConvictionLockdrops.sol/ConvictionLockdrops.json"); const tokenArtifact = await artifact("MockERC20.sol/MockERC20.json"); async function deploy(source: typeof conviction, args: readonly unknown[]) { const hash = await creatorWallet.deployContract({ abi: source.abi, bytecode: source.bytecode.object, args }); const receipt = await publicClient.waitForTransactionReceipt({ hash }); assert.equal(receipt.status, "success"); assert.ok(receipt.contractAddress); return receipt.contractAddress; } const conv = await deploy(conviction, [1_000_000n * 10n ** 18n, creatorAccount.address]); const vault = await deploy(vaultArtifact, [conv, creatorAccount.address]); const module = await deploy(moduleArtifact, [vault]); const token = await deploy(tokenArtifact, ["Demo reward", "DEMO", 6]); const eligibleToken = await deploy(tokenArtifact, ["Demo source", "SOURCE", 6]); const deployment = { chainId: 31337, module, vault }; const creator: Session = { publicClient, walletClient: creatorWallet, account: creatorAccount.address, deployment }; const recipient: Session = { publicClient, walletClient: recipientWallet, account: recipientAccount.address, deployment }; await write(creator, vault, vaultArtifact.abi, "setLocksPaused", [false]); const unit = 1_000_000n; await write(creator, token, tokenArtifact.abi, "mint", [creator.account, 10_000n * unit]); const tree = buildAllocations([ { recipient: recipient.account, amount: 250n * unit }, { recipient: unclaimedAccount.address, amount: 730n * unit }, ]); const outputDirectory = await mkdtemp(join(tmpdir(), "conviction-lockdrop-demo-")); const campaignPath = join(outputDirectory, "campaign.json"); const now = Number((await publicClient.getBlock()).timestamp); const file = await createMerkleCampaign(creator, { token, tree, claimStart: now, claimDeadline: now + 3_600, lockDuration: 30 * 86_400, }, draft => writeFile(campaignPath, JSON.stringify(draft, null, 2))); await writeFile(campaignPath, JSON.stringify(file, null, 2)); console.log(`Confirmed campaign ${file.campaignId}; portable allocation file: ${campaignPath}`); // Create real source positions before funding an existing-locker campaign. await write(creator, eligibleToken, tokenArtifact.abi, "mint", [recipient.account, 1_000n * unit]); await write(recipient, eligibleToken, erc20Abi, "approve", [vault, 1_000n * unit]); async function sourceLock(amount: bigint) { const receipt = await write(recipient, vault, vaultArtifact.abi, "lockUntilMaturity", [eligibleToken, amount, 90 * 86_400]); const logs = parseEventLogs({ abi: vaultArtifact.abi, eventName: "Locked", logs: receipt.logs.filter(log => log.address.toLowerCase() === vault.toLowerCase()) }); const args = logs[0]?.args as { id?: bigint } | undefined; assert.ok(args?.id !== undefined); return args.id; } const sources = [await sourceLock(400n * unit), await sourceLock(600n * unit)]; const sourceBefore = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "getPosition", args: [sources[0]] }); const funding = fundingForBudget(980n * unit); const locker = await createLockerCampaign(creator, { token, grossAmount: funding.grossAmount, eligibleToken, minPositionAmount: 100n * unit, minRemainingDuration: 7 * 86_400, claimStart: now, claimDeadline: now + 3_600, lockDuration: 30 * 86_400, minNetBudget: funding.budget, minPrincipalBasis: 1_000n * unit, maxPrincipalBasis: 1_000n * unit, }); const previews = await previewLockerSources(recipient, locker.campaignId, sources); assert.deepEqual(previews.map(value => value.amount), [392n * unit, 588n * unit]); assert.ok(previews.every(value => value.canSubmit)); const accepted = await claimLockerSources(recipient, locker.campaignId, [...sources].reverse()); assert.equal(accepted.amount, 980n * unit); assert.deepEqual(await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "getPosition", args: [sources[0]] }), sourceBefore); // Test-only time travel. Real integrations wait for actual block timestamps. async function advance(timestamp: number, mine = true) { const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "evm_setNextBlockTimestamp", params: [timestamp] }), }); const result = await response.json() as { error?: unknown }; assert.equal(result.error, undefined); if (mine) await publicClient.request({ method: "evm_mine" as never }); } // Mine the recipient's acceptance exactly one second before the exclusive deadline. await advance(file.claimDeadline - 1, false); const positionId = await claimAllocation(recipient, file, tree.allocations[0]); const position = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "getPosition", args: [positionId] }); assert.equal(position.owner.toLowerCase(), recipient.account.toLowerCase()); assert.equal(position.amount, 250n * unit); assert.equal(position.noEarlyExit, true); assert.equal(position.unlockAt - position.lockedAt, 30 * 86_400); assert.equal(await publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [recipient.account] }), 0n); await assert.rejects(claimAllocation(recipient, file, tree.allocations[0]), /already accepted/); await assert.rejects(releasePosition(recipient, positionId), /not active and mature/); assert.equal(position.lockedAt, file.claimDeadline - 1); console.log(`Accepted allocation into position ${positionId}; existing-locker shares into position ${accepted.positionId}.`); await advance(file.claimDeadline); const refund = await refundCampaign(creator, BigInt(file.campaignId!)); assert.equal(refund, 730n * unit); assert.equal(await publicClient.readContract({ address: module, abi: moduleArtifact.abi, functionName: "reserved", args: [token] }), 0n); const lockerPosition = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "getPosition", args: [accepted.positionId] }); await advance(Math.max(position.unlockAt, lockerPosition.unlockAt)); await releasePosition(recipient, positionId); await releasePosition(recipient, accepted.positionId); const finalBalance = await publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [recipient.account] }); assert.equal(finalBalance, 1_230n * unit); console.log(`PASS: refunded ${refund} raw units and released ${finalBalance} raw units after maturity.`); console.log(`Local deployment: ${JSON.stringify(deployment)}; reward token ${token}`);Verify the deployment before connecting funds
Use one chain ID, RPC, module address, vault address and reward token throughout the flow. Check deployed code at each contract and require
module.vault()to equal the expected vault. Read token decimals, current vaultterms()andlocksPaused().
Run the full lifecycle on a local chain
With Foundry installed, start from a Conviction source checkout with its contract dependencies initialized and compile the contracts. This demo uses disposable test accounts and a local Anvil chain.
cd contracts
forge buildIn a second terminal, start Anvil:
anvil --host 127.0.0.1 --port 8545 --chain-id 31337Back in the first terminal, run the repository copy of the demo. Its artifact paths depend on that location.
cd ../app
npm ci
node --import tsx src/docs/examples/lockdrops/local-demo.tsThe demo funds both campaign modes, accepts rewards, checks duplicate claims and premature release, refunds unused funding and releases mature principal. A successful run reports 730 tokens refunded and 1,230 tokens released across the two reward positions.
Turn allocations into verifiable claims
Commit to each recipient, their exact amount and a unique index. The onchain root fixes these entitlements for the life of the campaign.
Try a real allocation proof
Edit the allocation, then test what its proof permits.
Illustrative token with 0 decimals: one token equals one raw unit. These are fixed demo addresses.
0xe195bc79…5811535cChanging an allocation builds a new example commitment. A funded campaign’s root cannot change.
250 tokens to 0x11111111…11111111
The candidate leaf and sibling reconstruct the committed root. This proof establishes the allocation, not whether a live claim is available.
- 1. Double-hash the candidate
0x21358260…d1f09e55 - 2. Sort and hash with its sibling
0x43c9f043…65dd6a6e - 3. Compare the computed root
0xe195bc79…5811535cMatches commitment
Inspect complete proof data
Full addresses, amounts, hashes and the exact candidate proof are below. This offline example has no campaign ID and cannot be submitted as a funded campaign file.
{
"example": "Offline proof data, not a funded campaign",
"tokenDecimals": 0,
"committedRoot": "0xe195bc793401a1593e942f63cd70e144b93e7ef16e33a9b9d004b2a75811535c",
"committedAllocation": {
"index": "0",
"recipient": "0x1111111111111111111111111111111111111111",
"amount": "250",
"proof": [
"0x43c9f043805b86a0beaf5b71727c5930cbe3358a49facead6aa82dd365dd6a6e"
]
},
"candidateClaim": {
"index": "0",
"recipient": "0x1111111111111111111111111111111111111111",
"amount": "250",
"proof": [
"0x43c9f043805b86a0beaf5b71727c5930cbe3358a49facead6aa82dd365dd6a6e"
]
},
"trace": {
"leaf": "0x2135826088000c4cc9e57ca27729ba4e4c5c19f60b3a2f834847fc1ed1f09e55",
"steps": [
{
"sibling": "0x43c9f043805b86a0beaf5b71727c5930cbe3358a49facead6aa82dd365dd6a6e",
"computedHash": "0xe195bc793401a1593e942f63cd70e144b93e7ef16e33a9b9d004b2a75811535c"
}
],
"computedRoot": "0xe195bc793401a1593e942f63cd70e144b93e7ef16e33a9b9d004b2a75811535c"
},
"proofMatches": true
}Parse human-readable amounts using the reward token’s decimals. Keep amounts and indexes as bigint in code and decimal strings in JSON. Never calculate funding with JavaScript floating-point numbers.
Run the allocation builder without a wallet
After the setup above, save this entry point as prepare-airdrop.ts beside merkle.ts. It uses example addresses and an illustrative 18-decimal token, prints the root, proofs and funding quote, and makes no network calls.
import { parseUnits } from "viem";
import { buildAllocations, fundingForBudget } from "./merkle.ts";
// Offline example amounts use an illustrative token with 18 decimals.
// Replace these recipients and read your real token's decimals before funding.
const tree = buildAllocations([
{ recipient: "0x1111111111111111111111111111111111111111", amount: parseUnits("250", 18) },
{ recipient: "0x2222222222222222222222222222222222222222", amount: parseUnits("730", 18) },
]);
const funding = fundingForBudget(tree.budget);
console.log(JSON.stringify({ ...tree, funding },
(_, value) => typeof value === "bigint" ? value.toString() : value, 2));
npx tsx prepare-airdrop.tsThe output contains two allocations, their proofs and a net budget of 980000000000000000000 raw units. Save this output as a draft. It becomes a claimable campaign only after you fund it and bind it to the confirmed deployment and campaign ID.
// Exact Solidity leaf encoding used by ConvictionLockdrops.hashLeaf:
keccak256(bytes.concat(keccak256(abi.encode(index, recipient, amount))))
// Types: ["uint256", "address", "uint256"]
// Pair hash: keccak256(concat(min(left, right), max(left, right)))
// A single allocation has an empty proof: []- Validate the list
- Require valid nonzero recipient addresses, positive amounts, unique indexes and a total allocation no larger than the net budget. Duplicate indexes share one claimed bit, even if their recipients differ. The contract checks each proof; it cannot verify your full allocation sum.
- Use standard proofs
- Use the supplied builder or OpenZeppelin
StandardMerkleTreewith["uint256", "address", "uint256"]. Leaves are double-hashed and internal pairs sorted. Proofs have at most 32 siblings. A single leaf uses an empty proof. - Keep indexes distinct
- An allocation’s explicit
indexis part of its leaf. OpenZeppelingetProof(entryOffset)takes the tree entry offset; that offset is not necessarily the allocation index. Preserve the index alongside the recipient, amount and proof. - Bind the claim file
- Save chain ID, module, vault, campaign ID, creator, token, root, amount, index, proof, claim window and lock duration. Verify them against the funded campaign before offering acceptance. The leaf itself has no campaign ID; the campaign root and bitmap determine where it can be spent.
The kit’s CampaignFile is a compact format for your own app and these helpers. It is separate from the hosted app’s conviction-lockdrop-v1 import format.
Approve the module, then fund once
The creator funds the recipient reserve plus the fixed fee. Approve ConvictionLockdrops for the gross amount, then call createCampaign.
Try the funding calculation
Change the amount and precision to see exactly where tokens go.
Amounts use exact integer arithmetic. The fee rounds down to raw token units.
- Gross funding
- tokens1000000000 raw units
- Recipient budget
- tokens980000000 raw units
- Creator fee
- tokens20000000 raw units
Feefloor(1000000000 / 50) = 20000000
Budget1000000000 - 20000000 = 980000000
quoteFunding before funding a real campaign.fee = grossAmount / 50n; // Integer division: round down.
budget = grossAmount - fee; // Reserve for recipients.
// Example in raw token units:
grossAmount = 1_000n;
fee = 20n;
budget = 980n;
// For an exact target budget B >= 49n:
minimumGross = max(50n, B + (B - 1n) / 49n);Use quoteFunding(grossAmount) to confirm the fee and budget. Gross funding must be at least 50 raw units. Set minNetBudget to the allocation total so the transaction rejects an insufficient reserve. The helper requires a target budget of at least 49 raw units, the net reserve left by minimum valid funding.
- 1Review and approve
Show token, spender, gross amount, fee and terms.
- 2Simulate and create
Wait for approval success, then submit the campaign.
- 3Confirm and save
Decode CampaignCreated from the module’s receipt.
- Creation parameters
token,grossAmount,merkleRoot,claimStart,claimDeadline,lockDurationandminNetBudget. Timestamps and duration are seconds; amounts are raw token units.- Window and duration
- Require
claimStart < claimDeadline. The deadline must be in the future and within 365 days of creation, including any delay before opening. The reward duration must satisfy current vault bounds. New locks must be unpaused. - Immutable funding
- After creation, allocations, dates and duration cannot change. There is no campaign cancellation. Fees go through the vault’s CONV removal path and are never refunded.
Connect the creator action to the helper
Build a Session with your viem public client, wallet client, selected account and deployment: { chainId, module, vault }. The helper accepts your allocation tree and reviewed terms, saves the draft through your callback before any transaction, and returns the confirmed campaign file.
import { createMerkleCampaign } from "./campaigns.ts";
// Call after the creator reviews and accepts these terms.
const file = await createMerkleCampaign(creatorSession, {
token, tree, claimStart, claimDeadline, lockDuration,
}, saveDraft); // Persist the full draft before approval.
await saveConfirmedCampaign(file); // Includes the confirmed campaign ID.Supply your app’s storage callbacks and user acceptance. The kit provides transaction primitives; your app must implement durable pending-state guards and recovery. The optional onSubmitted callback exposes each transaction hash after submission.
Make acceptance an explicit user action
Show the allocation and its commitment before opening the wallet. Only the recipient can call claim; neither the creator nor your app can force acceptance.
Try accepting a locked airdrop
250 tokens. A full 30-day lock. No early exit.
Claims: day 1 inclusive to day 30 exclusive. The campaign is funded and the recipient’s proof is valid.
Choose a caller, time and vault policy, then try an action. Each failed attempt leaves the sandbox balances unchanged.
- Campaign reserve
- tokens
- Locked principal
- tokens
- Recipient wallet
- tokens
noEarlyExit = trueBefore each action, check the wallet and chain, read getCampaign, require the Merkle mode, verify the proof and unclaimed index, and recheck the claim window and vault policy. Use the chain’s block timestamp. A successful simulation is useful preflight; only a successful receipt confirms the claim.
Use claimAllocation(recipientSession, confirmedFile, allocation) from the kit for the verified flow and returned position ID. Its underlying transaction is:
// Inside the recipient's explicit "Accept and lock" action.
// publicClient / walletClient are viem clients for the same chain.
const { request } = await publicClient.simulateContract({
address: moduleAddress,
abi: lockdropsAbi,
functionName: "claim",
account: recipient,
args: [campaignId, BigInt(allocation.index), recipient, BigInt(allocation.amount), allocation.proof],
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Claim reverted");
// Decode this module's Claimed event to obtain positionId.
// Confirm getPosition(positionId) on the bound vault before showing success.The module approves the vault internally and moves the full allocation into lockUntilMaturityFor. The resulting position has the recipient as owner and noEarlyExit = true. The recipient approves no reward tokens and receives no immediately spendable allocation.
Decode Claimed only from the expected module and validate the campaign, index, recipient and amount. Record the returned position as chain ID + vault address + position ID, then read it from the vault. Do not predict a position ID from nextId().
Separate the claim window from the lock
The campaign deadline decides when recipients can accept. The lock duration decides how long each accepted allocation stays committed.
- Claim
claimStart ≤ block.timestamp < claimDeadline. A claim one second before the deadline still creates a full-duration lock from that block’s timestamp.- Refund
- At
block.timestamp ≥ claimDeadline, only the campaign creator can callrefund(campaignId). It returnsbudget - claimed - refunded. Claimed principal and fees stay outside the refund. No allocation file is needed. - Release
- At or after the position’s
unlockAt, anyone can call the vault’srelease(positionId). Principal always goes to the recorded owner. The example’sreleasePositionhelper selects that owner for its wallet flow. Before maturity, no early exit is available. - Extend
- The owner may extend the position to a later maturity under current vault terms. Extension preserves its no-early-exit restriction. Always read the latest
unlockAt.
A failed claim rolls back its claimed bit, counters, reserve changes and approvals. Vault pauses or changed duration bounds can block new claims without consuming allocations; refunds remain possible after the original deadline.
Use existing locks as the allocation rule
If the reward belongs to holders who already committed, let the contract calculate each allocation. No recipient list or Merkle proofs are needed.
reward = floor(budget × source.amount / principalBasis)
980 × 1,000 / 10,000 = 98 reward tokens
principalBasis = vault.totalLocked(eligibleToken) at fundingCall createLockerCampaign with the reward token, gross amount, qualifying token, minimum principal per position, minimum remaining duration, claim dates, reward duration, minimum net budget and accepted principal-basis bounds. The source kit’s existing-lockers.ts covers this flow.
- Funding snapshot
- The module records
vault.nextId()as an exclusive source cutoff andtotalLocked(eligibleToken)as the fixed principal basis. It rejects an empty basis or one outsideminPrincipalBasisandmaxPrincipalBasis. A basis outside accepted bounds requires renewed review. - Source eligibility
- The caller must own each source. It must predate the cutoff, hold the eligible token, remain Active and unmatured, meet both minimums and have a nonzero reward. The minimum amount applies to each position separately. Extension can make an old position qualify; a new or recreated position cannot join.
- Preview and discover
- Page owned positions with bounded reads and preserve an incomplete state while more pages remain.
previewLockerClaimreturns amount and status independently of the claim window and caller ownership. Also check campaign existence, mode, dates, wallet ownership and current vault policy. - Accept a batch
- Pass 1 to 32 strictly increasing, unique source IDs to
claimLockerCampaign. The whole batch succeeds or reverts together. One new reward position receives the combined shares; each source emits aClaimedevent with the same new position ID. - Keep shares fixed
- The basis includes matured but unreleased principal and positions excluded by the rules. Excluded shares are not redistributed. Unclaimed and unallocated funding is refundable after the deadline. Claim order does not increase another source’s allocation.
The original source positions keep their amount, owner, maturity and any early-exit rights. Each may claim once per campaign. The accepted reward is a separate lock with its own full duration and no early exit.
Understand the deployment and custody
Reuse or deploy the module
Use a verified compatible module, or deploy ConvictionLockdrops(vault) with a compatible vault address. No registration or vault administrative transaction is required. The module has no owner, upgrade, cancellation or rescue function.
A new module is still bound to one vault
Campaign accounting is separate, but the module pools each token’s campaign reserves. Claimed funds join the bound vault’s principal. Deploying another module against that vault does not create dedicated vault custody.
- Shared exposure
- A module exploit can threaten campaign reserves; a vault exploit can threaten accepted principal across projects. Separate accounting and no early exit do not protect against contract defects. The team can discuss dedicated vault requirements.
- Token behavior
- Fee-on-transfer and rebasing tokens are unsupported. Exact balance checks reject observable shortfalls, and an aggregate deficit blocks operations for that token. Hostile transfer restrictions or dishonest balances remain a risk. Accidental donations are not recoverable.
- Wallet state
- Bind every action to its reviewed account and chain. Revalidate after any account, network or deployment change. Display pending hashes and successful receipts; prevent duplicate submissions until an interrupted transaction is reconciled.
- Before real funding
- Exercise invalid proofs, duplicate claims, start/deadline boundaries, late claims, vault pauses, refunds, maturity releases and wallet changes. Confirm deployed source and ABI compatibility, including
noEarlyExitin the vault position tuple.
Build your locked airdrop.
Use the kit in your product, or talk to the Conviction team about token compatibility, allocation rules and dedicated custody.
For ordinary user-funded positions, see Build with CONV locks. For the hosted campaign experience, read the Lockdrops user guide.