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
| Mode | Distribution | Verification |
|---|---|---|
| Link | Creator hands out signed messages (one per voucher) | ECDSA eth_personal_sign recovered on-chain |
| Whitelist | Creator publishes a Merkle root; wallets self-redeem | Merkle proof verified on-chain |
Both modes share the same ETH-locked pool and same redeem-to-swap flow.
Roles
| Caller | What they can do |
|---|---|
| Creator of a campaign | createCampaign(...), createCampaignWhitelist(...), cancelCampaign, cancelAll |
| Recipient of a voucher | redeem(...), redeemWhitelist(...) |
| anyone | All 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 | GRADUATEDat creation (rejects QUEUE/WARMUP/RESTING/ELIMINATED/LOST)
Redemption — Link mode (signed vouchers)
function redeem(
uint256 campaignId,
uint256 voucherIndex,
bytes calldata signature,
uint256 minTokensOut
) external returns (uint256 tokenOut);- Verify
ECDSA.recover(voucherDigest, signature) == campaign.signer. - Mark voucher consumed; mark wallet as claimed (one-per-wallet per campaign).
- Token state must still be
INIT | BATTLE | GRADUATED. poolManager.unlock(...)→ exact-input ETH→TOKEN swap ofcampaign.ethPerVoucher, slippage floor =minTokensOut.- 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
| Error | Cause | Fix |
|---|---|---|
BadEthValue | msg.value != ethPerVoucher × totalVouchers | Send the exact total |
BadEthPerVoucher | ethPerVoucher < MIN_ETH_PER_VOUCHER | Use ≥ 0.001 ETH |
TooManyVouchers | totalVouchers > MAX_VOUCHERS_PER_CAMPAIGN | Use ≤ 1000 vouchers per campaign |
BadTokenState | Token isn’t in `INIT | BATTLE |
CampaignNotFound | Invalid campaignId | Use nextCampaignId() - 1 |
AlreadyRedeemed | Voucher index used | Pick another index, or already done |
AlreadyClaimedInCampaign | Wallet already redeemed this Link campaign | One per wallet per campaign |
BadSignature | Signature doesn’t recover to campaign.signer | Re-sign with the right keypair using voucherDigest() |
OverAllocation | Whitelist wallet trying to redeem past allocation | Wait or check whitelistRedeemedBy |
BadProof | Merkle proof invalid | Use the tree at campaign.merkleRoot; format leaves with whitelistLeaf(wallet, alloc) |
NotCreator | Non-creator called cancelCampaign | Only the original creator can cancel |
SlippageTooHigh | Pool would deliver less than minTokensOut | Increase 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.