Skip to Content
⚔ BattleMeme docs · early preview · expect rough edges
ContractsVoucherManager

VoucherManager

Independent creator-promo / airdrop utility. Anyone can pre-fund ETH for a token, then distribute redemption rights (either via off-chain ECDSA signatures or a Merkle whitelist). Recipients redeem on-chain; the contract auto-buys the token via the V4 pool and forwards the output. Orthogonal to the battle lifecycle — vouchers are not part of queue, scoring, or finalize.

Source: contracts/src/core/VoucherManager.sol · ReentrancyGuard.

Campaign modes

ModeDistributionVerification
LinkCreator hands out signed messages (one per voucher)ECDSA eth_personal_sign recovered on-chain
WhitelistCreator publishes a Merkle root; wallets self-redeemMerkle proof verified on-chain

Both modes share the same ETH-locked pool and same redeem-to-swap flow.

Roles

CallerWhat they can do
Creator of a campaigncreateCampaign(...), createCampaignWhitelist(...), cancelCampaign, cancelAll
Recipient of a voucherredeem(...), redeemWhitelist(...)
anyoneAll view functions, voucherDigest helper

Public API

Campaign creation

function createCampaign( address token, uint128 ethPerVoucher, uint32 totalVouchers, address signer ) external payable returns (uint256 campaignId); function createCampaignWhitelist( address token, uint128 ethPerVoucher, uint32 totalVouchers, bytes32 merkleRoot ) external payable returns (uint256 campaignId);

Both:

  • Require msg.value == ethPerVoucher × totalVouchers
  • Require ethPerVoucher ≥ MIN_ETH_PER_VOUCHER (0.001 ETH)
  • Require totalVouchers ≤ MAX_VOUCHERS_PER_CAMPAIGN (1000)
  • Token’s state must be INIT | BATTLE | GRADUATED at creation (rejects QUEUE/WARMUP/RESTING/ELIMINATED/LOST)
function redeem( uint256 campaignId, uint256 voucherIndex, bytes calldata signature, uint256 minTokensOut ) external returns (uint256 tokenOut);
  1. Verify ECDSA.recover(voucherDigest, signature) == campaign.signer.
  2. Mark voucher consumed; mark wallet as claimed (one-per-wallet per campaign).
  3. Token state must still be INIT | BATTLE | GRADUATED.
  4. poolManager.unlock(...) → exact-input ETH→TOKEN swap of campaign.ethPerVoucher, slippage floor = minTokensOut.
  5. Forward purchased tokens to msg.sender.

Redemption — Whitelist mode (Merkle)

function redeemWhitelist( uint256 campaignId, uint32 allocation, bytes32[] calldata proof, uint256 minTokensOut ) external returns (uint256 tokenOut);

Verifies (msg.sender, allocation) is a leaf of campaign.merkleRoot using the OZ StandardMerkleTree double-hash convention:

leaf = keccak256(bytes.concat(keccak256(abi.encode(wallet, allocation))))

Each wallet can redeem up to allocation times. Use whitelistRedeemedBy(campaignId, wallet) to check progress.

The contract exposes a whitelistLeaf(wallet, allocation) helper so off-chain tree-builders use the exact format the contract verifies.

Cancellation

function cancelCampaign(uint256 campaignId, uint256[] calldata voucherIndexes) external returns (uint256 cancelledCount, uint256 refundedEth); function cancelAll(uint256 campaignId) external returns (uint256 cancelledCount, uint256 refundedEth);

Refund all un-redeemed slots’ ETH to the campaign creator. Already-redeemed slots are silently skipped (idempotent). cancelAll is a fast-path that sets the campaign-level cancelled flag so future redeems short-circuit.

Views

function getCampaign(uint256 campaignId) external view returns (Campaign memory); function isVoucherRedeemed(uint256 campaignId, uint256 voucherIndex) external view returns (bool); function hasClaimedInCampaign(uint256 campaignId, address user) external view returns (bool); // link mode function whitelistRedeemedBy(uint256 campaignId, address user) external view returns (uint32); // wl mode function voucherDigest(uint256 campaignId, uint256 voucherIndex) external view returns (bytes32); function whitelistLeaf(address wallet, uint32 allocation) external pure returns (bytes32); function nextCampaignId() external view returns (uint256);

voucherDigest returns the EIP-191 personal-sign envelope of the inner hash:

inner = keccak256(this, chainId, campaignId, voucherIndex) digest = keccak256("\x19Ethereum Signed Message:\n32" || inner)

Sign digest with the campaign’s signer keypair using eth_personal_sign (most wallets) or signMessage.

Campaign struct

struct Campaign { address creator; address token; uint128 ethPerVoucher; uint32 totalVouchers; uint32 redeemedCount; uint32 cancelledCount; uint8 kind; // 1 = Link, 2 = Whitelist address signer; // Link mode only bytes32 merkleRoot; // Whitelist mode only bool cancelled; bool exists; }

Events

event CampaignCreated(uint256 indexed campaignId, address indexed creator, address indexed token, uint128 ethPerVoucher, uint32 totalVouchers, uint8 kind); event VoucherRedeemed(uint256 indexed campaignId, uint256 indexed voucherIndex, address indexed recipient, uint256 tokenOut); event CampaignCancelled(uint256 indexed campaignId, address indexed creator, uint256 refundedEth);

Errors

ErrorCauseFix
BadEthValuemsg.value != ethPerVoucher × totalVouchersSend the exact total
BadEthPerVoucherethPerVoucher < MIN_ETH_PER_VOUCHERUse ≥ 0.001 ETH
TooManyVoucherstotalVouchers > MAX_VOUCHERS_PER_CAMPAIGNUse ≤ 1000 vouchers per campaign
BadTokenStateToken isn’t in `INITBATTLE
CampaignNotFoundInvalid campaignIdUse nextCampaignId() - 1
AlreadyRedeemedVoucher index usedPick another index, or already done
AlreadyClaimedInCampaignWallet already redeemed this Link campaignOne per wallet per campaign
BadSignatureSignature doesn’t recover to campaign.signerRe-sign with the right keypair using voucherDigest()
OverAllocationWhitelist wallet trying to redeem past allocationWait or check whitelistRedeemedBy
BadProofMerkle proof invalidUse the tree at campaign.merkleRoot; format leaves with whitelistLeaf(wallet, alloc)
NotCreatorNon-creator called cancelCampaignOnly the original creator can cancel
SlippageTooHighPool would deliver less than minTokensOutIncrease the swap window or lower expectations

Common usage patterns

Creator side — sign a voucher off-chain

import { encodeAbiParameters, keccak256, hashMessage } from "viem"; import { ADDRESSES } from "@/lib/addresses"; // Inner hash (matches Solidity) const inner = keccak256( encodeAbiParameters( [{ type: "address" }, { type: "uint256" }, { type: "uint256" }, { type: "uint256" }], [ADDRESSES.VoucherManager, BigInt(chainId), BigInt(campaignId), BigInt(voucherIndex)] ) ); // EIP-191 envelope const digest = hashMessage({ raw: inner }); const signature = await walletClient.signMessage({ account, message: { raw: inner } }); // Hand `{ campaignId, voucherIndex, signature }` to the recipient (URL/QR/DM).

Recipient side — redeem

const tokenOut = await walletClient.writeContract({ address: ADDRESSES.VoucherManager, abi: VoucherManagerAbi, functionName: "redeem", args: [campaignId, voucherIndex, signature, minTokensOut], });

Whitelist build & verify

import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; // Off-chain build const tree = StandardMerkleTree.of( [ [wallet1, allocation1], [wallet2, allocation2], ], ["address", "uint32"] ); const merkleRoot = tree.root; // publish `merkleRoot` on createCampaignWhitelist; share leaves & proofs to wallets. // Recipient side const proof = tree.getProof([wallet, allocation]); await walletClient.writeContract({ address: ADDRESSES.VoucherManager, abi: VoucherManagerAbi, functionName: "redeemWhitelist", args: [campaignId, allocation, proof, minTokensOut], });

See also: Vouchers (protocol), Use vouchers.