Conviction For builders

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

  1. 01 / Creator

    Fund the campaign

    Approve the module.
    Deposit the gross amount.

    2% creator fee
  2. 02 / Module

    Reserve allocations

    Hold the recipient budget.
    Verify each accepted claim.

    Immutable campaign terms
  3. 03 / Vault

    Create the user’s lock

    The recipient opts in.
    Their principal stays until maturity.

    No early exit
Before acceptance, funds are campaign reserves in ConvictionLockdrops. After acceptance, they are recipient-owned principal in ConvictionVault. The creator cannot take back a claimed allocation.

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.

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.

  1. 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 tsx
  2. Download 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}`);
    
  3. 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 vault terms() and locksPaused().

You can build allocation proofs offline. Funding and claims need a compatible deployment and a wallet. The helper files work in a new folder; the local demo needs the repository's compiled contracts.

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.

Compile the contracts
cd contracts
forge build

In a second terminal, start Anvil:

Start the local test chain
anvil --host 127.0.0.1 --port 8545 --chain-id 31337

Back in the first terminal, run the repository copy of the demo. Its artifact paths depend on that location.

Run the complete Lockdrop lifecycle
cd ../app
npm ci
node --import tsx src/docs/examples/lockdrops/local-demo.ts

The 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.

Allocation budget980 tokens
Committed root0xe195bc79…5811535c

Changing an allocation builds a new example commitment. A funded campaign’s root cannot change.

Inspect recipient
Test this claim
Candidate claim for index 0

250 tokens to 0x11111111…11111111

Recipient A’s exact indexed allocation, with its original proof.
Proof matches

The candidate leaf and sibling reconstruct the committed root. This proof establishes the allocation, not whether a live claim is available.

  1. 1. Double-hash the candidate0x21358260…d1f09e55
  2. 2. Sort and hash with its sibling0x43c9f043…65dd6a6e
  3. 3. Compare the computed root0xe195bc79…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.

lockdrop-proof-example.json
{
  "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
}
Real hashes from the downloadable allocation builder, calculated in your browser. No wallet, RPC call or transaction. A live claim also requires the right caller, an open claim window, an unclaimed index and an available vault.

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.

prepare-airdrop.ts
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));
Run the offline allocation example
npx tsx prepare-airdrop.ts

The 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.

Merkle leaf encoding
// 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 StandardMerkleTree with ["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 index is part of its leaf. OpenZeppelin getProof(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.

Calculate from
Try an amount

Amounts use exact integer arithmetic. The fee rounds down to raw token units.

Gross funding
1000 tokens1000000000 raw units
Recipient budget
980 tokens980000000 raw units
Creator fee
20 tokens20000000 raw units

Feefloor(1000000000 / 50) = 20000000

Budget1000000000 - 20000000 = 980000000

Local illustration using the implementation kit’s funding math. No wallet connection or network calls. Read your token’s decimals and confirm with quoteFunding before funding a real campaign.
Funding arithmetic
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.

  1. 1
    Review and approve

    Show token, spender, gross amount, fee and terms.

  2. 2
    Simulate and create

    Wait for approval success, then submit the campaign.

  3. 3
    Confirm and save

    Decode CampaignCreated from the module’s receipt.

Creation parameters
token, grossAmount, merkleRoot, claimStart, claimDeadline, lockDuration and minNetBudget. 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.

Create a campaign from your app
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.

Day 10 · 864000 seconds

Claims: day 1 inclusive to day 30 exclusive. The campaign is funded and the recipient’s proof is valid.

Ready to simulate

Choose a caller, time and vault policy, then try an action. Each failed attempt leaves the sandbox balances unchanged.

Campaign reserve
980 tokens
Locked principal
0 tokens
Recipient wallet
0 tokens
No position yetA claim creates the lock and fixes its maturity.noEarlyExit = true
Local model of one funded Merkle allocation, with no wallet or network calls. It illustrates caller, timing, pause and duplicate checks. Live claims also depend on valid proofs, token transfers and contract state. Refunds are separate from this sandbox.

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

The recipient claim transaction
// 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.

Day 10

Claims open on day 0. Day 30 closes claims and opens refunds.

Claim on day 10Unlock on day 4030 full days locked
Claiming later moves the unlock date by the same amount. It never shortens the new reward lock. Your original qualifying position keeps its original terms.
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 call refund(campaignId). It returns budget - 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’s release(positionId). Principal always goes to the recorded owner. The example’s releasePosition helper 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.

Fixed reward for each source position
reward = floor(budget × source.amount / principalBasis)

980 × 1,000 / 10,000 = 98 reward tokens

principalBasis = vault.totalLocked(eligibleToken) at funding

Call 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 and totalLocked(eligibleToken) as the fixed principal basis. It rejects an empty basis or one outside minPrincipalBasis and maxPrincipalBasis. 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. previewLockerClaim returns 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 a Claimed event 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 noEarlyExit in 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.