Signature Distributor¶
The EDDSA Distributor verifies claims with EDDSA (Ed25519) signatures instead of Merkle proofs. An airdrop stores an authorized signer rather than a Merkle root, and each claim needs that signer's signature. There is no Merkle tree to generate in advance, so the signer can authorize claims as the distribution runs.
Differences from the Merkle Distributor¶
| Feature | Merkle Distributor | EDDSA Distributor |
|---|---|---|
| Verification | Merkle proofs | Ed25519 signatures |
| Data Storage | Merkle root | Authorized signer |
| Claim Data | Fixed per root (the owner can replace the root) | Dynamic per claim |
| Flexibility | Limited | High |
System overview¶
Account structure¶
Storage pattern¶
The program has one config account. It stores the admin's address, the default fee collector's address, and the address of a pending new admin for two-step ownership transfers.
One deployment can manage many airdrops. Each airdrop is stored as follows:
pub struct Airdrop {
pub owner: Pubkey,
pub authorized_signer: Pubkey,
pub token: Pubkey,
pub start_time: u64,
pub end_time: u64,
pub fee_collector: Pubkey,
pub fee_token: Pubkey,
pub paused: bool,
}
Each processed claim creates a Claim account. Because the account is created with init, a second claim with the same claim ID fails:
PDA seeds¶
| Account | Seeds | Count |
|---|---|---|
config |
"config" |
One per program |
version |
"version" |
One per program (created by version) |
airdrop |
"airdrop", project_id |
One per project ID |
vault |
"vault", airdrop address |
One per airdrop; token authority is the airdrop PDA |
claim |
"claim", airdrop address, user_claim_id |
One per claimed claim ID |
Claim mechanism¶
A claim is submitted with this instruction:
pub fn claim(
ctx: Context<Claim>,
project_id: String,
recipient: Pubkey,
user_claim_id: [u8; 32],
group: [u8; 32],
user_claim_data: EDDSADistributorData,
signature: Vec<u8>
) -> Result<()>
The program does not verify the signature itself. The transaction must also contain an Ed25519 native program instruction that verifies signature from the airdrop's authorized_signer over the claim hash; claim() finds that instruction through the instructions sysvar. The claim hash is the Keccak-256 hash of the Borsh-serialized UserClaimData struct (group is not part of it). The encode_hash_to_be_signed instruction returns this hash.
pub struct UserClaimData {
pub recipient: Pubkey,
pub user_claim_id: [u8; 32],
pub user_claim_data: EDDSADistributorData,
pub airdrop: Pubkey,
}
Any account can submit a claim with a valid signature. Tokens are always sent to the recipient's associated token account; the submitting account pays the fee and account rent.
Instructions¶
| Function | Description |
|---|---|
initialize() |
Initialize an airdrop, create required accounts |
set_base_params() |
Sets token, time window, and authorized signer |
claim() |
Processes one signature-authorized claim |
toggle_pause() |
Emergency pause functionality |
deposit() |
Deposit tokens to be airdropped |
withdraw() |
Owner withdraws the full vault balance |
encode_hash_to_be_signed() |
Returns the hash the authorized signer must sign |
Distributor variants¶
eddsa-token-distributor-solana¶
This is the standard implementation. It supports both SPL Token and Token-2022 tokens, claims are time-locked by claimable_timestamp, and the owner withdraws unclaimed tokens with the standard withdrawal function. Its claim data is:
eddsa-token-with-fees-distributor-solana¶
This variant adds a fee to the claim data. The fee is part of what the authorized signer signs, so each claim can carry its own fee amount (see the fee system below).
pub struct EDDSADistributorData {
pub claimable_timestamp: u64,
pub claimable_amount: u64,
pub fees: u64,
}
Fee system¶
On every claim, the distributor makes a CPI to the fee collector. The account that signs the claim transaction pays the fee. It is not deducted from the claimed tokens.
The fee collector is the external FeeCollector program, which calculates fees and manages the vault accounts. Each airdrop stores its own fee_collector, copied from default_fee_collector at initialization. How the fee is calculated depends on the variant:
- The base program calls
collect_feewith the claimed amount. The fee collector reads the project'sFeeaccount (seeds"fee", distributor program ID,project_id). If that account is set to default, the fee isclaimable_amount * default_fees_bips / 10000. Otherwise the fee collector uses a fixed fee if one is set, and custom bips if not. - The with-fees program calls
collect_fee_amountwithuser_claim_data.fees. The fee collector charges that amount as given and does not use the project'sFeeaccount.
Fees are paid in native SOL when fee_token is the default public key, and in an SPL token otherwise. The program admin sets the fee token per airdrop with set_fee_token.
Fee tokens are held in Program Derived Accounts (PDAs). SOL fees go to the fee collector's storage PDA, and SPL fees go to the fee collector's vault PDA (seeds "vault", fee token mint).
Security¶
Access control¶
| Role | Permissions |
|---|---|
| Program Admin | Set default fee collector, set an airdrop's fee collector and fee token, transfer program admin |
| Airdrop Owner | Set parameters (token, time window, authorized signer), toggle pause, withdraw tokens, transfer airdrop ownership |
| Authorized Signer | Sign claim authorizations |
| Users | Submit claims with valid signatures |
deposit has no owner check: any account can deposit the airdrop's token into the vault.
Claim safeguards¶
The airdrop owner can pause claims in an emergency. Each claim needs a matching Ed25519 signature, verified through the Ed25519 native program, and must fall inside the distribution window. The per-claim-ID account blocks a second claim with the same ID.
Events and errors¶
Events¶
| Event | Description |
|---|---|
Deployed |
Program config created by deploy |
Initialized |
Successful airdrop initialization (project_id) |
ClaimDelegateSet |
Defined, but never emitted: these programs have no claim delegate |
Claimed |
Successful token claim. The recipient field holds the signer of the claim |
Errors¶
| Error | Condition |
|---|---|
UnsupportedOperation |
start_time is not before end_time in set_base_params |
TimeInactive |
Outside distribution window |
InvalidSignature |
No matching Ed25519 instruction from the authorized signer over the claim hash |
OutsideClaimableTimeRange |
claimable_timestamp is not after start_time and before now |
NotOwner |
Unpermissioned account trying to call an owner-permissioned function |
NotProgramAdmin |
Unpermissioned account trying to call a program admin-permissioned function |
NotPermissioned |
Unpermissioned account trying to receive program admin status |
InvalidFeeCollector |
Provided fee collector account is incorrect |
Paused |
Airdrop has been paused |
A repeated claim ID fails because the claim account already exists; there is no dedicated error code for it.