Whale Bait
Whale tax of 3–8% paid to holders under 20M
A trade hook and a fee hook that work together.
What it does
Bordrless’s reading of the source, written 14 Oct, 09:48 UTC. Read by AI from the code, not an audit.
Taxes buys and sells by wallets holding 5M+ tokens at 3–8%, and the script sells the tax for SOL and pays its pot to wallets under 20M tokens.
The hook takes 3% of the tokens on buys and sells by wallets holding 5M+ tokens, 5% from 10M and 8% from 20M. A buy is rated by the balance after it and a sell by the balance before it. Transfers are free and no transfer is refused. The tax lands with the companion. The script sells up to 0.5% of the pool's tokens per step, priced against a slow reference. Each 5-minute epoch it budgets the whole pot to wallets under 20M tokens, pro rata to what they held before the epoch, up to 8 a step.
The most it takes
Up to 8% of a trade's tokens for wallets of 5M+, nothing below
Every rule
When it applies, what it takes, and where that goes.
| On | When | What happens | At most | To |
|---|---|---|---|---|
| Trades | Under 5M tokens | 0% hook tax | 0% | — |
| Trades | 5M–10M tokens | 3% of the tokens on buys (balance after) and sells (balance before), to the companion | 3% | The pot |
| Trades | 10M–20M tokens | 5% of the tokens on buys and sells, to the companion, sold for SOL for holders | 5% | The pot |
| Trades | 20M+ tokens | 8% of the tokens on buys and sells, to the companion, sold for SOL for holders | 8% | The pot |
| Transfers | Transfers | free | 0% | — |
| Each step | Each step | Sells the taxed tokens, at most 0.5% of the pool's tokens, for SOL into the pot | 0% | The pot |
| Each step | Every 5 minutes | The pot pays wallets under 20M tokens pro rata to tokens held before the epoch, once each | 100% | Holders |
What to watch
Things a launcher and a buyer should know before they use it.
- mediumNothing taxed or paid until the companion address's token holding exists and holds taxed tokens beyond the dev bag.
- lowTiers are per wallet and transfers are free, so a whale can split its tokens to dodge the tax and collect payouts.
- lowThe whole pot is budgeted every 5 minutes; a wallet that holds just across an epoch boundary gets its share.
- lowOnly a few holders are paid per step, and holders without a bridged-SOL holding are never paid.
Security review
Taxes buys and sells by wallets holding 5M+ tokens at 3–8%, and the script sells the tax for SOL and pays its pot to wallets under 20M tokens.
- mediumThe pair: payouts depend on a companion token holding nothing here creates
Hook src/lib.rs beforetransfer takes no cut unless the companion address's token holding exists (creatorready). The script pays only while that holding holds tokens beyond the dev bag (receiptsleft), and never creates it. Without a dev buy or someone creating it, there is no tax and no payouts, and the pot sits idle. The trading test measured 0. Fix: create the holding in companioninit or in the…
- mediumThe hook: whale tiers and the 20M cap are per wallet, and transfers are free
ratebps and eligiblepart look at one holding's balance. A 30M holder can send to fresh wallets under 5M for free and then sell untaxed. The same split lets it collect dividends from the next epoch. The comment says a whale cannot dodge its tier, which holds only within one trade. Either say so plainly or tax transfers that split a balance.
- mediumThe script: the entire pot is the budget of every 5-minute epoch
companionstep sets epochbudget = pendingpot at each epoch roll. A wallet bought just before a boundary is paid for its whole balance in the next epoch's first steps and can sell right after. With a large pot relative to eligible holdings, this beats the trading fees. Consider a budget fraction per epoch, or a longer holding requirement.
- lowThe script: few holders paid per epoch, and many never
The keeper brings 3 holders a step (the code allows up to 8). With many holders, most are skipped each epoch and their share rolls over. Holders without a bridged-SOL holding are skipped always. Who gets paid depends on the order the keeper brings them in.
- lowThe script: the sale accepts prices up to about 3% below the reference, minus slippage
saleminout allows the 3% band, 0.5% impact, fees and 2% slippage, so a sale can be pushed down about 5% on each step. stepreference can also move the reference 50% at once after a quiet spell. Exposure is bounded to 0.5% of the pool's base side per step.
- lowThe script: a receipt can push a holder to exactly 20M tokens
The 1-unit receipt goes to a holding just under ELIGIBLEBELOW. That can lift it to 20M, dropping it from eligibletotal and from future payouts.
- infoThe script: payments are capped by pendingpot read at step start
Payments come from the companion address's bridged-SOL holding and are capped at companion.pendingpot read at step start. Sale proceeds count toward later payouts only as far as the companion reflects them in pendingpot.
Checks
Approved 13 Oct, 04:09 UTC by its checks
- The template’s rulesPassed
No calls into other programs, no SOL moved, no unsafe code, no reads from the builder.
- Built on Bordrless’s builderPassed
177,848 bytes, hash b88c5dbed54c75fce918c7e4d9ce462a3c88c376626c1ed89ab5c9baeb0f009b.
- Trading testPassed
Bought, sold and sent by test wallets over a simulated year; the largest cut it took was 0%.
- What it does, read by AINotes
It found: The pair: payouts depend on a companion token holding nothing here creates; The hook: whale tiers and the 20M cap are per wallet, and transfers are free; The script: the entire pot is the budget of every 5-minute epoch.
- Fee hook: The template’s rulesPassed
No SOL moved by itself, no unsafe code, no reads from the builder; it calls the DEX to trade its pot.
- Fee hook: Built on Bordrless’s builderPassed
202,952 bytes, hash a66ac75d86ec478331ba38517ece6ab030f77115d784bf9870e588f22e6517b4.
What the trading test covered
- Simulated a launch at unix time 1791849600 with a 100 bps creator fee: the creator and 6 fresh wallets per round bought, sent to each other, partly sold and sold out at +31s, +1h, +1d, +7d, +30d, +365d, then 50 more trades and a last round.
- The program also implements after_transfer, before_mint, after_mint, after_burn, which the flags do not name: turned on, they changed nothing in the simulation.
- Not caught: a rule triggered by a specific amount or by a condition the simulation never reproduces (a price, a supply, a balance or a number of trades it does not reach).
- Not caught: a rule aimed at specific hard-coded wallets (the simulation's creator and traders are its own keys, not the real ones).
- Not caught: anything after +365 days, and behaviour after graduation (the simulation trades on the launch pool and never fills the curve).
On chain
Track record
Wallets buying/holding over 5M tokens get taxed 3%, 10M tokens 5% and 8% for 20m+ fees go to holders holding under 20m
Source: the trade hook
The source Bordrless checked and built. Read it before you launch with it.
src/lib.rs698 lines ›
//! Whale Bait: the token hook of the Whale Bait pair (a Bordrless Workshop pair: this hook on the
//! trades, the `whale_bait_dividend` companion script on the creator fee).
//!
//! **Buys and sells are both taxed, in tokens; sends between wallets are not.** The cut is 3% of
//! the tokens traded from 5M tokens, 5% from 10M, 8% from 20M or more (`rate_bps`; never more than
//! 8% of a trade):
//! - **a buy** (a transfer out of the launch pool to a wallet) by what the buyer holds **after** it,
//! the whole amount bought counted, so a buy rated at 8% can leave a wallet just under 20M once
//! the cut is taken (that wallet then counts as a holder);
//! - **a sell** (a transfer from a wallet into the launch pool) by what the seller holds **before**
//! it, so a whale cannot dodge its tier by selling down past it in one trade.
//!
//! The cut is taken from the tokens traded (the buyer gets the rest; the pool gets the rest of a
//! sale) and lands in the companion's creator address's holding of the token (one delta), where the
//! companion script sells it for bridged SOL and pays the SOL out to the holders under 20M tokens.
//! Wallet-to-wallet sends pay nothing; the launch, its pool and the creator address never pay (the
//! companion's own sales and buys are untaxed); no transfer is ever refused.
//!
//! What this hook records for the script (the two halves share only these layouts):
//! - its state at `["state", mint]` (`WhaleState`, 198 bytes: the layout below) keeps
//! `eligible_total`, the sum of the balances of every wallet holding under 20M tokens (strictly),
//! the launch, its pool and the creator address left out, updated from both holdings' balances on
//! every transfer and from the burner's on every burn; and `epoch_secs`, the length of a dividend
//! epoch (5 minutes);
//! - a record in the 64 bytes of hook data of every wallet's holding, two things in one layout:
//! - **the receipt**: when the creator address sends exactly one base unit to a wallet (what the
//! script sends with every payment), the record's `paid_epoch` becomes the current epoch,
//! `floor(unix_time / 300)` from the Clock sysvar. The script reads it to pay each holder once
//! an epoch;
//! - **what arrived this epoch**: every other transfer into a wallet (a buy, net of its cut; a
//! send from anyone) adds what arrives to `in_amount` when `in_epoch` is the current epoch, or
//! starts it over at that amount with `in_epoch` the current epoch. Nothing that arrives in an
//! epoch is forgotten until the epoch is over, and the receipt itself never counts. The script
//! pays a holder for its balance less `in_amount` of the current epoch: what it held before
//! the epoch, so tokens paid in one wallet and sent to another are not paid again in the same
//! epoch, while a holder sent dust, or one that keeps buying, is paid for what it had.
//!
//! Only this hook writes the record, through its `before_transfer` answer; each write keeps the
//! other part as it was. A holding left empty by a transfer or a burn has its record zeroed (so
//! the token program lets its owner close it): with nothing in it, nothing held before the epoch
//! is lost, and whatever arrives later in the epoch counts as arrived.
//!
//! The 64-byte hook data layout (`Record`, `read_record`, `with_receipt`, `with_received`):
//! byte 0 version: 1 once written; 0 is nothing written
//! bytes 1..5 paid_epoch: u32 little-endian, the 5 minutes (unix time / 300) the holder was last
//! paid in (0: never)
//! bytes 5..9 in_epoch: u32 little-endian, the 5 minutes of the last transfer into the holding,
//! receipts aside (0: none)
//! bytes 9..17 in_amount: u64 little-endian, everything transferred into it in `in_epoch`,
//! receipts aside
//! bytes 17..64 zero
//!
//! The state's layout (`WhaleState`, Anchor's 8-byte discriminator first):
//! 8 version u8 (1) 9 bump u8 10 mint [32] 42 launch [32]
//! 74 creator [32] 106 creator_holding [32] 138 epoch_secs u32 (300)
//! 142 eligible_total u64 150 cut_total u64 158 receipts u64 166 reserved [32]
//!
//! Flags for its config: `BEFORE_TRANSFER | BEFORE_BURN | TRANSFER_RETURNS_DELTA |
//! WRITES_HOOK_DATA` (209). The creator's holding of the token must exist for a cut to be paid (the
//! companion makes it at its first step, and anyone may make it earlier); until then whale trades
//! pass free.
//!
//! The standard `prepare` (every Workshop hook has it, unchanged in its accounts): before a launch,
//! anyone sends it once for the new mint (the launch does, for its own); it creates this hook's state
//! for the mint at `["state", mint]` and writes its registry, the list of extra accounts every
//! callback receives after the five the token program always passes: the state (writable), the
//! mint's launch account (read-only: its pool tells a buy), and the creator address's holding of
//! the mint (writable: it receives the cut).
#![allow(unexpected_cfgs)]
use anchor_lang::prelude::*;
use bordrless_game::launch::read_launch;
use bordrless_game::{companion_creator_address, eligible};
use bordrless_hook::{
hook_accounts_address, hook_signer, write_registry, AccountSource, Delta, ExtraAccount,
HookAccountList, HookReturn, Seed, TokenHookArgs, TokenOp, HOOK_DATA_LEN,
TOKEN_PREFIX_ACCOUNTS,
};
declare_id!("pUQJqJjD8trNh1uQUr8Mp3s9UVTAbHKyvLJarqbj7mg");
/// The Bordrless token program: the only caller of the callbacks, and the owner of every holding.
pub const TOKEN_PROGRAM: Pubkey =
Pubkey::from_str_const("2XoEWp8cF3kRXg74eVwPAyTFhVCAztn3V88komxAvr22");
/// The Bordrless launchpad: its `["launch", mint]` account names the mint's pool.
pub const LAUNCH_PROGRAM: Pubkey =
Pubkey::from_str_const("1jcBymHxBjniZDhNPy51Vgm5Nz7pLUdxa9UBHc4TavC");
/// `["state", mint]`: this hook's state for a mint (the standard `prepare` creates it).
pub const STATE_SEED: &[u8] = b"state";
/// The launchpad's `["launch", mint]` seed.
pub const LAUNCH_SEED: &[u8] = b"launch";
/// The token program's `["holding", mint, owner]` seed.
pub const HOLDING_SEED: &[u8] = b"holding";
/// Layout version of [`WhaleState`].
pub const STATE_VERSION: u8 = 1;
/// Layout version of the record in a holding's hook data; 0 is nothing written.
pub const RECORD_VERSION: u8 = 1;
/// A dividend epoch: 5 minutes. A receipt stamps the epoch the holder was paid in; what arrives is
/// counted by the epoch it arrives in.
pub const EPOCH_SECS: u32 = 300;
/// One whole token in base units (a launched token has 6 decimals).
pub const ONE_TOKEN: u64 = 1_000_000;
/// The tiers, by what the trader holds (a buyer after the buy, a seller before the sale), and the
/// cut each takes of the tokens traded.
pub const TIER_1_TOKENS: u64 = 5_000_000 * ONE_TOKEN;
pub const TIER_1_BPS: u64 = 300;
pub const TIER_2_TOKENS: u64 = 10_000_000 * ONE_TOKEN;
pub const TIER_2_BPS: u64 = 500;
pub const TIER_3_TOKENS: u64 = 20_000_000 * ONE_TOKEN;
pub const TIER_3_BPS: u64 = 800;
/// A wallet counts in `eligible_total` (and is paid by the script) while it holds less than this:
/// 20M tokens.
pub const ELIGIBLE_BELOW: u64 = 20_000_000 * ONE_TOKEN;
pub const BPS: u64 = 10_000;
/// What the creator address sends a holder with each payment: one base unit, the receipt.
pub const RECEIPT_AMOUNT: u64 = 1;
/// Index of the creator's holding in a callback's accounts (the five, the state, the launch).
pub const CREATOR_HOLDING_INDEX: u8 = TOKEN_PREFIX_ACCOUNTS as u8 + 2;
/// The flags a config names for this hook: `BEFORE_TRANSFER | BEFORE_BURN | TRANSFER_RETURNS_DELTA |
/// WRITES_HOOK_DATA` (209).
pub const FLAGS: u16 = bordrless_hook::token_flags::BEFORE_TRANSFER
| bordrless_hook::token_flags::BEFORE_BURN
| bordrless_hook::token_flags::TRANSFER_RETURNS_DELTA
| bordrless_hook::token_flags::WRITES_HOOK_DATA;
/// The holding of `owner` for `mint` (the token program's PDA).
pub fn holding_address(mint: &Pubkey, owner: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[HOLDING_SEED, mint.as_ref(), owner.as_ref()], &TOKEN_PROGRAM).0
}
/// The cut a trade pays, in basis points of the tokens traded, by what the trader holds: a buyer
/// after the buy, a seller before the sale.
pub fn rate_bps(held: u64) -> u64 {
if held >= TIER_3_TOKENS {
TIER_3_BPS
} else if held >= TIER_2_TOKENS {
TIER_2_BPS
} else if held >= TIER_1_TOKENS {
TIER_1_BPS
} else {
0
}
}
/// The cut of a trade of `amount` by a trader holding `held` (a buyer after the buy, a seller before
/// the sale): `amount * rate / 10,000`, rounded down (at most 8% of the amount, so always below it).
pub fn cut_of(amount: u64, held: u64) -> u64 {
let cut = u128::from(amount) * u128::from(rate_bps(held)) / u128::from(BPS);
u64::try_from(cut).unwrap_or(u64::MAX).min(amount)
}
/// What a balance adds to `eligible_total`: itself while under `ELIGIBLE_BELOW`, nothing from there.
pub fn eligible_part(balance: u64) -> u64 {
if balance < ELIGIBLE_BELOW {
balance
} else {
0
}
}
/// The epoch of unix time `now` with epochs of `epoch_secs`: `floor(now / epoch_secs)`, 0 for a
/// time before 1970 or an epoch length of 0.
pub fn epoch_of(now: i64, epoch_secs: u32) -> u32 {
if epoch_secs == 0 || now <= 0 {
return 0;
}
u32::try_from((now as u64) / u64::from(epoch_secs)).unwrap_or(u32::MAX)
}
/// A holding's record in its hook data (the layout is in the module comment).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Record {
/// The epoch the holder was last paid in (its receipt); 0 when never.
pub paid_epoch: u32,
/// The epoch of the last transfer into the holding, receipts aside; 0 when none.
pub in_epoch: u32,
/// Everything transferred into the holding in `in_epoch`, receipts aside.
pub in_amount: u64,
}
/// The record a holding's hook data holds; all zeros when nothing was written (or another layout).
pub fn read_record(data: &[u8; HOOK_DATA_LEN]) -> Record {
if data[0] != RECORD_VERSION {
return Record::default();
}
let mut amount = [0u8; 8];
amount.copy_from_slice(&data[9..17]);
Record {
paid_epoch: u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
in_epoch: u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
in_amount: u64::from_le_bytes(amount),
}
}
/// The hook data of a record.
pub fn record_data(r: &Record) -> [u8; HOOK_DATA_LEN] {
let mut data = [0u8; HOOK_DATA_LEN];
data[0] = RECORD_VERSION;
data[1..5].copy_from_slice(&r.paid_epoch.to_le_bytes());
data[5..9].copy_from_slice(&r.in_epoch.to_le_bytes());
data[9..17].copy_from_slice(&r.in_amount.to_le_bytes());
data
}
/// The hook data after a receipt in `epoch`: the record's `paid_epoch`, what arrived kept as it was.
pub fn with_receipt(current: &[u8; HOOK_DATA_LEN], epoch: u32) -> [u8; HOOK_DATA_LEN] {
let mut r = read_record(current);
r.paid_epoch = epoch;
record_data(&r)
}
/// The hook data after `amount` arrives in `epoch` (not a receipt): added to what arrived in that
/// epoch (saturating), or that amount alone when the record is of an earlier epoch; the receipt
/// kept as it was.
pub fn with_received(current: &[u8; HOOK_DATA_LEN], epoch: u32, amount: u64) -> [u8; HOOK_DATA_LEN] {
let mut r = read_record(current);
r.in_amount = if r.in_epoch == epoch {
r.in_amount.saturating_add(amount)
} else {
amount
};
r.in_epoch = epoch;
record_data(&r)
}
/// The epoch a holding was last paid in, from its hook data; `None` without a receipt.
pub fn read_receipt(data: &[u8; HOOK_DATA_LEN]) -> Option<u32> {
let paid = read_record(data).paid_epoch;
(paid != 0).then_some(paid)
}
/// The callbacks' extra accounts, in order after the token program's five (hook signer, mint,
/// source, destination, authority): this hook's state for the mint (writable), the mint's launch
/// account (read-only), and the creator address's holding of the mint (writable: the cut's
/// destination). Each is derived from the mint alone: `creator` is the companion's
/// `["creator", mint]`, known once the mint is.
pub fn extra_accounts(creator: &Pubkey) -> HookAccountList {
HookAccountList::new(vec![
ExtraAccount {
writable: true,
source: AccountSource::Pda {
program: crate::ID,
seeds: vec![Seed::Literal(STATE_SEED.to_vec()), Seed::Account(1)],
},
},
ExtraAccount {
writable: false,
source: AccountSource::Pda {
program: LAUNCH_PROGRAM,
seeds: vec![Seed::Literal(LAUNCH_SEED.to_vec()), Seed::Account(1)],
},
},
ExtraAccount {
writable: true,
source: AccountSource::Pda {
program: TOKEN_PROGRAM,
seeds: vec![
Seed::Literal(HOLDING_SEED.to_vec()),
Seed::Account(1),
Seed::Literal(creator.to_bytes().to_vec()),
],
},
},
])
}
#[program]
pub mod whale_bait_hook {
use super::*;
/// The standard `prepare`: this hook's state for `mint` and its registry. `mint` need not exist
/// yet (a launch's mint does not until the launch). Anyone may send it, once per mint.
pub fn prepare(ctx: Context<Prepare>) -> Result<()> {
let mint = ctx.accounts.mint.key();
let creator = companion_creator_address(&mint);
let state = &mut ctx.accounts.state;
state.version = STATE_VERSION;
state.bump = ctx.bumps.state;
state.mint = mint;
state.launch = Pubkey::find_program_address(&[LAUNCH_SEED, mint.as_ref()], &LAUNCH_PROGRAM).0;
state.creator = creator;
state.creator_holding = holding_address(&mint, &creator);
state.epoch_secs = EPOCH_SECS;
state.eligible_total = 0;
state.cut_total = 0;
state.receipts = 0;
state.reserved = [0; 32];
let (registry, bump) = hook_accounts_address(&crate::ID, &mint);
require_keys_eq!(ctx.accounts.registry.key(), registry, HookError::WrongAccount);
write_registry(
&ctx.accounts.payer.to_account_info(),
&ctx.accounts.registry.to_account_info(),
&ctx.accounts.system_program.to_account_info(),
&crate::ID,
&mint,
bump,
&extra_accounts(&creator),
)
}
/// Before every transfer: a buy (out of the launch pool) into a wallet that ends up holding a
/// tier's worth, or a sell (into the launch pool) from a wallet that held a tier's worth before
/// it, pays that tier's cut of the tokens traded to the creator's holding; `eligible_total`
/// follows both sides' balances; a one-unit send from the creator address stamps the receiver's
/// receipt, and any other transfer into a wallet adds what arrives to its record of this epoch.
/// Never refuses.
pub fn before_transfer(ctx: Context<Callback>, args: TokenHookArgs) -> Result<HookReturn> {
check_call(&ctx, &args)?;
if args.op != TokenOp::Transfer || args.source == args.destination || args.amount == 0 {
return Ok(HookReturn::default());
}
// The creator's holding must exist for the token program to pay into it.
let creator_ready = *ctx.accounts.creator_holding.owner == TOKEN_PROGRAM;
let state = &mut ctx.accounts.state;
let pool = read_launch(&ctx.accounts.launch, &state.mint).map(|l| l.pool);
let excluded = [state.launch, state.creator, pool.unwrap_or_default()];
// The cut, from the tokens traded: a buy (out of the launch pool) by what the buyer holds
// after it, the whole amount counted; a sell (into the launch pool) by what the seller holds
// before it. The launch, its pool and the creator address never pay: the companion's own
// trades (its sales, its dev buy) are untaxed, and a cut can't go to the very holding that
// sends or receives it, which the token program refuses. Sends between wallets pay nothing.
let buy = pool.is_some_and(|p| args.source_owner == p)
&& !excluded.contains(&args.destination_owner);
let sell = pool.is_some_and(|p| args.destination_owner == p)
&& !excluded.contains(&args.source_owner);
let cut = if !creator_ready {
0
} else if buy {
cut_of(args.amount, args.destination_balance.saturating_add(args.amount))
} else if sell {
cut_of(args.amount, args.source_balance)
} else {
0
};
// The eligible total: each side's part before, replaced by its part after (the sender loses
// the whole amount, the receiver gets it less the cut; the creator address is never counted).
let source_eligible = eligible(&args.source_owner, &excluded);
let destination_eligible = eligible(&args.destination_owner, &excluded);
let mut total = state.eligible_total;
if source_eligible {
let after = args.source_balance.saturating_sub(args.amount);
total = total
.saturating_sub(eligible_part(args.source_balance))
.saturating_add(eligible_part(after));
}
if destination_eligible {
let after = args
.destination_balance
.saturating_add(args.amount.saturating_sub(cut));
total = total
.saturating_sub(eligible_part(args.destination_balance))
.saturating_add(eligible_part(after));
}
state.eligible_total = total;
state.cut_total = state.cut_total.saturating_add(cut);
let mut answer = HookReturn::default();
// A holding the transfer empties has its record zeroed, so its owner can close it.
if args.source_balance == args.amount && args.source_hook_data != [0u8; HOOK_DATA_LEN] {
answer.source_hook_data = Some([0u8; HOOK_DATA_LEN]);
}
if destination_eligible {
let epoch = epoch_of(Clock::get()?.unix_timestamp, EPOCH_SECS);
if args.source_owner == state.creator && args.amount == RECEIPT_AMOUNT {
// The receipt: the creator address paying a holder sends one base unit with the
// payment. It is not counted as arrived.
answer.destination_hook_data = Some(with_receipt(&args.destination_hook_data, epoch));
state.receipts = state.receipts.saturating_add(1);
} else {
// What arrives (a buy's tokens net of the cut, a send's) counts for this epoch.
answer.destination_hook_data = Some(with_received(
&args.destination_hook_data,
epoch,
args.amount.saturating_sub(cut),
));
}
}
if cut > 0 {
answer.deltas = vec![Delta {
amount: cut,
account: CREATOR_HOLDING_INDEX,
}];
}
Ok(answer)
}
/// After every transfer: the balances are the new ones. Answers nothing.
pub fn after_transfer(ctx: Context<Callback>, args: TokenHookArgs) -> Result<()> {
check_call(&ctx, &args)
}
/// Before the supply is minted (once, at launch).
pub fn before_mint(ctx: Context<Callback>, args: TokenHookArgs) -> Result<HookReturn> {
check_call(&ctx, &args)?;
Ok(HookReturn::default())
}
/// After the supply is minted.
pub fn after_mint(ctx: Context<Callback>, args: TokenHookArgs) -> Result<()> {
check_call(&ctx, &args)
}
/// Before a burn (a holder's own, the launch's on trades and at graduation): `eligible_total`
/// follows the burner's balance, and a holding the burn empties has its record zeroed (so its
/// owner can close it). Never refuses.
pub fn before_burn(ctx: Context<Callback>, args: TokenHookArgs) -> Result<HookReturn> {
check_call(&ctx, &args)?;
if args.op != TokenOp::Burn || args.amount == 0 {
return Ok(HookReturn::default());
}
let state = &mut ctx.accounts.state;
let pool = read_launch(&ctx.accounts.launch, &state.mint).map(|l| l.pool);
let excluded = [state.launch, state.creator, pool.unwrap_or_default()];
if eligible(&args.source_owner, &excluded) {
let after = args.source_balance.saturating_sub(args.amount);
state.eligible_total = state
.eligible_total
.saturating_sub(eligible_part(args.source_balance))
.saturating_add(eligible_part(after));
}
let mut answer = HookReturn::default();
if args.source_balance == args.amount && args.source_hook_data != [0u8; HOOK_DATA_LEN] {
answer.source_hook_data = Some([0u8; HOOK_DATA_LEN]);
}
Ok(answer)
}
/// After a burn.
pub fn after_burn(ctx: Context<Callback>, args: TokenHookArgs) -> Result<()> {
check_call(&ctx, &args)
}
}
/// Every callback: signed by the token program's signer for this hook (no other program can call
/// it as the token program), about the mint this state was prepared for.
fn check_call(ctx: &Context<Callback>, args: &TokenHookArgs) -> Result<()> {
let signer = &ctx.accounts.hook_signer;
require!(signer.is_signer, HookError::BadHookSigner);
require_keys_eq!(
signer.key(),
hook_signer(&TOKEN_PROGRAM, &crate::ID).0,
HookError::BadHookSigner
);
require_keys_eq!(args.mint, ctx.accounts.state.mint, HookError::WrongAccount);
Ok(())
}
/// This hook's state for one mint, at `["state", mint]` (198 bytes; the offsets are in the module
/// comment).
#[account]
#[derive(InitSpace)]
pub struct WhaleState {
/// Layout version.
pub version: u8,
/// Bump.
pub bump: u8,
/// The mint.
pub mint: Pubkey,
/// The launchpad's `["launch", mint]`: never counted, never paid.
pub launch: Pubkey,
/// The companion's `["creator", mint]`: the cut's owner, never counted, never paid.
pub creator: Pubkey,
/// The creator's holding of the mint: where every cut lands.
pub creator_holding: Pubkey,
/// A dividend epoch, seconds: the unit of a receipt's `paid_epoch`.
pub epoch_secs: u32,
/// The sum of the balances of the wallets holding under `ELIGIBLE_BELOW`.
pub eligible_total: u64,
/// Tokens cut from whale trades (buys and sells) so far.
pub cut_total: u64,
/// Receipts written so far.
pub receipts: u64,
/// Reserved, zero.
pub reserved: [u8; 32],
}
impl WhaleState {
/// Account size.
pub const LEN: usize = 8 + Self::INIT_SPACE;
}
/// Accounts of the standard `prepare` (the same in every Workshop hook).
#[derive(Accounts)]
pub struct Prepare<'info> {
/// Pays the rent.
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: the mint the hook is prepared for; need not exist yet.
pub mint: UncheckedAccount<'info>,
#[account(init, payer = payer, space = 8 + WhaleState::INIT_SPACE, seeds = [STATE_SEED, mint.key().as_ref()], bump)]
pub state: Account<'info, WhaleState>,
/// CHECK: the registry PDA, written here (address-checked in the handler).
#[account(mut)]
pub registry: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
/// Accounts of every callback: the token program's five, then the registry's extras.
#[derive(Accounts)]
pub struct Callback<'info> {
/// CHECK: the token program's signer for this hook (checked in `check_call`).
pub hook_signer: UncheckedAccount<'info>,
/// CHECK: the mint.
pub mint: UncheckedAccount<'info>,
/// CHECK: the source holding (the mint for a mint).
pub source: UncheckedAccount<'info>,
/// CHECK: the destination holding (the mint for a burn).
pub destination: UncheckedAccount<'info>,
/// CHECK: who signed the operation.
pub authority: UncheckedAccount<'info>,
#[account(mut, seeds = [STATE_SEED, mint.key().as_ref()], bump = state.bump)]
pub state: Account<'info, WhaleState>,
/// CHECK: the mint's launch account (address-checked; it may not exist yet).
#[account(address = state.launch @ HookError::WrongAccount)]
pub launch: UncheckedAccount<'info>,
/// CHECK: the creator's holding of the mint (address-checked; it may not exist yet).
#[account(mut, address = state.creator_holding @ HookError::WrongAccount)]
pub creator_holding: UncheckedAccount<'info>,
}
/// Errors.
#[error_code]
pub enum HookError {
#[msg("an account is not the one this mint's hook expects")]
WrongAccount,
#[msg("the hook signer is not the token program's signer for this hook")]
BadHookSigner,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_programs_are_the_game_standards() {
assert_eq!(TOKEN_PROGRAM, bordrless_game::TOKEN_PROGRAM_ID);
assert_eq!(LAUNCH_PROGRAM, bordrless_game::LAUNCH_PROGRAM_ID);
assert_eq!(LAUNCH_SEED, bordrless_game::LAUNCH_SEED);
// BEFORE_TRANSFER 1, BEFORE_BURN 16, TRANSFER_RETURNS_DELTA 64, WRITES_HOOK_DATA 128.
assert_eq!(FLAGS, 209);
let mint = Pubkey::new_unique();
assert_eq!(
Pubkey::find_program_address(&[LAUNCH_SEED, mint.as_ref()], &LAUNCH_PROGRAM).0,
bordrless_game::launch_address(&mint)
);
}
#[test]
fn the_tiers() {
assert_eq!(rate_bps(0), 0);
assert_eq!(rate_bps(TIER_1_TOKENS - 1), 0);
assert_eq!(rate_bps(TIER_1_TOKENS), 300);
assert_eq!(rate_bps(TIER_2_TOKENS - 1), 300);
assert_eq!(rate_bps(TIER_2_TOKENS), 500);
assert_eq!(rate_bps(TIER_3_TOKENS - 1), 500);
assert_eq!(rate_bps(TIER_3_TOKENS), 800);
assert_eq!(rate_bps(u64::MAX), 800);
assert_eq!(cut_of(1_000_000, 0), 0);
assert_eq!(cut_of(1_000_000, TIER_1_TOKENS), 30_000);
assert_eq!(cut_of(1_000_000, TIER_2_TOKENS), 50_000);
assert_eq!(cut_of(1_000_000, TIER_3_TOKENS), 80_000);
assert_eq!(
cut_of(u64::MAX, u64::MAX),
(u128::from(u64::MAX) * 800 / 10_000) as u64
);
assert!(cut_of(u64::MAX, u64::MAX) < u64::MAX);
assert_eq!(cut_of(1, TIER_3_TOKENS), 0);
// A sell is rated by what the seller holds before it: 21M selling 2M pays 8%, 4M nothing.
assert_eq!(cut_of(2_000_000 * ONE_TOKEN, 21_000_000 * ONE_TOKEN), 160_000 * ONE_TOKEN);
assert_eq!(cut_of(4_000_000 * ONE_TOKEN, 4_000_000 * ONE_TOKEN), 0);
}
#[test]
fn the_eligible_part_and_the_epoch() {
assert_eq!(ELIGIBLE_BELOW, 20_000_000_000_000);
assert_eq!(EPOCH_SECS, 300);
assert_eq!(eligible_part(0), 0);
assert_eq!(eligible_part(19_900_000 * ONE_TOKEN), 19_900_000 * ONE_TOKEN);
assert_eq!(eligible_part(ELIGIBLE_BELOW - 1), ELIGIBLE_BELOW - 1);
assert_eq!(eligible_part(ELIGIBLE_BELOW), 0);
assert_eq!(eligible_part(u64::MAX), 0);
assert_eq!(epoch_of(0, EPOCH_SECS), 0);
assert_eq!(epoch_of(-5, EPOCH_SECS), 0);
assert_eq!(epoch_of(299, EPOCH_SECS), 0);
assert_eq!(epoch_of(300, EPOCH_SECS), 1);
assert_eq!(epoch_of(3_600, EPOCH_SECS), 12);
assert_eq!(epoch_of(1_800_000_000, EPOCH_SECS), 6_000_000);
assert_eq!(epoch_of(1_800_000_299, EPOCH_SECS), 6_000_000);
assert_eq!(epoch_of(1_800_000_300, EPOCH_SECS), 6_000_001);
assert_eq!(epoch_of(1_800_000_000, 0), 0);
assert_eq!(epoch_of(i64::MAX, 1), u32::MAX);
}
#[test]
fn the_record_keeps_the_receipt_and_what_arrived_apart() {
// The receipt alone (v1's layout is the record with nothing arrived).
let data = with_receipt(&[0; HOOK_DATA_LEN], 500_123);
assert_eq!(data[0], 1);
assert_eq!(&data[1..5], &500_123u32.to_le_bytes());
assert!(data[5..].iter().all(|b| *b == 0));
assert_eq!(read_receipt(&data), Some(500_123));
assert_eq!(read_receipt(&[0; HOOK_DATA_LEN]), None);
let mut other = data;
other[0] = 2;
assert_eq!(read_receipt(&other), None);
assert_eq!(read_record(&other), Record::default());
// What arrives in an epoch adds up and is kept until the epoch is over; a later epoch starts
// over; the receipt is left as it was.
let a = with_received(&data, 500_124, 40);
assert_eq!(
read_record(&a),
Record { paid_epoch: 500_123, in_epoch: 500_124, in_amount: 40 }
);
let b = with_received(&a, 500_124, 2);
assert_eq!(read_record(&b).in_amount, 42);
assert_eq!(&b[5..9], &500_124u32.to_le_bytes());
assert_eq!(&b[9..17], &42u64.to_le_bytes());
assert!(b[17..].iter().all(|x| *x == 0));
let c = with_received(&b, 500_125, 7);
assert_eq!(
read_record(&c),
Record { paid_epoch: 500_123, in_epoch: 500_125, in_amount: 7 }
);
// A receipt leaves what arrived as it was (and is not counted as arrived).
let d = with_receipt(&b, 500_124);
assert_eq!(
read_record(&d),
Record { paid_epoch: 500_124, in_epoch: 500_124, in_amount: 42 }
);
// Saturating, never wrapping.
let full = with_received(&with_received(&[0; HOOK_DATA_LEN], 9, u64::MAX - 1), 9, 5);
assert_eq!(read_record(&full).in_amount, u64::MAX);
}
#[test]
fn the_state_is_198_bytes_at_its_offsets() {
let s = WhaleState {
version: STATE_VERSION,
bump: 253,
mint: Pubkey::new_unique(),
launch: Pubkey::new_unique(),
creator: Pubkey::new_unique(),
creator_holding: Pubkey::new_unique(),
epoch_secs: EPOCH_SECS,
eligible_total: 7,
cut_total: 8,
receipts: 9,
reserved: [0; 32],
};
let mut data = Vec::new();
s.try_serialize(&mut data).unwrap();
assert_eq!(data.len(), WhaleState::LEN);
assert_eq!(WhaleState::LEN, 198);
assert_eq!(data[8], 1);
assert_eq!(data[9], 253);
assert_eq!(&data[10..42], s.mint.as_ref());
assert_eq!(&data[42..74], s.launch.as_ref());
assert_eq!(&data[74..106], s.creator.as_ref());
assert_eq!(&data[106..138], s.creator_holding.as_ref());
assert_eq!(&data[138..142], &300u32.to_le_bytes());
assert_eq!(&data[142..150], &7u64.to_le_bytes());
assert_eq!(&data[150..158], &8u64.to_le_bytes());
assert_eq!(&data[158..166], &9u64.to_le_bytes());
assert_eq!(&data[166..], &[0u8; 32][..]);
}
#[test]
fn the_registry_resolves_to_three_extras_from_the_mint_alone() {
let mint = Pubkey::new_unique();
let creator = companion_creator_address(&mint);
let list = extra_accounts(&creator);
let prefix = [
Pubkey::new_unique(),
mint,
Pubkey::new_unique(),
Pubkey::new_unique(),
Pubkey::new_unique(),
];
let metas = list
.resolve(&prefix, &Pubkey::new_unique(), &Pubkey::new_unique())
.unwrap();
assert_eq!(metas.len(), 3);
assert_eq!(
metas[0].pubkey,
Pubkey::find_program_address(&[STATE_SEED, mint.as_ref()], &crate::ID).0
);
assert!(metas[0].is_writable);
assert_eq!(metas[1].pubkey, bordrless_game::launch_address(&mint));
assert!(!metas[1].is_writable);
assert_eq!(metas[2].pubkey, holding_address(&mint, &creator));
assert!(metas[2].is_writable);
assert_eq!(usize::from(CREATOR_HOLDING_INDEX), 5 + 2);
}
}
Source: the fee hook
The source Bordrless checked and built. Read it before you launch with it.
src/lib.rs993 lines ›
//! Whale Bait dividend: the companion script of the Whale Bait pair (a Bordrless Workshop pair: the
//! `whale_bait_hook` token hook on the trades, this script on the creator fee).
//!
//! The hook cuts 3% to 8% of every whale trade, buys and sells (a buy that leaves a wallet holding
//! 5M tokens or more, a sell by a wallet holding 5M or more before it) into the creator address's
//! holding of the token. This script, called by the companion with the creator address signing,
//! turns those tokens into bridged SOL and pays the SOL to the holders under 20M tokens, each in
//! proportion to what they hold, once an epoch (5 minutes):
//!
//! - **The sale.** Whatever the creator's token holding holds above the dev bag still vesting (the
//! companion's `dev_tokens - dev_released`) is the script's: it sells it on the launch pool for
//! bridged SOL (keeping one token for receipts). Steps are anyone's, so a sale is made so that
//! wrapping a step in a sell and a buy back gains nothing:
//! - **a reference price no transaction can move**, kept in the state: it starts at the
//! companion's opening price (set at the launch, before anyone could trade) and follows the
//! pool's price by at most `REF_BPS_PER_MINUTE` a minute since it last moved;
//! - **no sale while the pool's price is more than `SALE_BAND_BPS` below that reference**, and a
//! minimum out from the reference (less that band, the sale's own impact, the fees in force and
//! `SLIPPAGE_BPS`), which the DEX enforces;
//! - **at most `SALE_POOL_SHARE_BPS` of the pool's base side a step**, so the price a sale moves
//! stays below the fees a sandwich around it pays;
//! - **no sale while the launch's sniper fee is above its own** (the sale would hand up to 80%
//! of the tokens' worth to the pool); the math uses the LP fee in force.
//!
//! The proceeds grow the pot.
//! - **The epoch** is 5 minutes, `floor(unix_time / 300)`: the hook's epoch, so what the hook
//! records as arrived in the current epoch is exactly what arrived since it began (a longer epoch
//! would span several of the hook's, and a holding's 64 bytes of hook data cannot keep them all).
//! `init_data` is empty (or 300 written out). The first step of an epoch snapshots the pot as the
//! epoch's budget and the hook's `eligible_total` (the sum of every wallet under 20M tokens) as
//! the denominator.
//! - **The payouts.** For each holding the sender passes (up to `MAX_PAYOUTS` a step): a holding
//! of the mint, owned by the token program, under 20M tokens, whose owner is a wallet (not the
//! launch, its pool or the creator address) with no receipt for this epoch, the script pays for
//! **what it held before the epoch**: its balance less everything that arrived in it during the
//! epoch (the hook's record; the receipts aside), at least 0. Tokens paid in one wallet and sent
//! to another are not paid again in the same epoch; what arrives during an epoch, by a buy or a
//! send, counts from the next; a holder sent dust, or one that keeps buying, is paid for what it
//! had. The share is `held * epoch_budget / eligible_total_snapshot` lamports of bridged SOL
//! (against the hook's `eligible_total` as it stands instead, when that is larger: a holder that
//! crossed under 20M after the snapshot gets its share of the holders it joined), sent from the
//! creator's bridged-SOL holding to the holder's (a holder without one is skipped: a step has
//! nobody to pay rent), then one base unit of the token from the creator's token holding to the
//! holder's: the receipt, which the hook stamps with the epoch. The tokens that cannot be paid
//! this epoch stay in the denominator: their shares are handed to nobody and stay in the pot for
//! the next epoch's budget. What an epoch pays never exceeds its budget, nor the companion's
//! `pending_pot` as it stands.
//! - A step that sold nothing, rolled no epoch and paid nobody fails (`NothingToDo`), so an idle
//! step earns no bounty from the pot.
//!
//! The hook's records this script reads (the two halves share only these layouts):
//! - the hook's state, `PDA(["state", mint])` under the hook program the mint names
//! (`Mint.hook_program`), 198 bytes: `[8] version u8 = 1`, `[10..42] mint`, `[138..142]
//! epoch_secs u32 LE` (300), `[142..150] eligible_total u64 LE`;
//! - a holding's 64 bytes of hook data, its record: `[0] version u8` (1 once the hook wrote it, else
//! 0), `[1..5] paid_epoch u32 LE` (the 5 minutes, unix time / 300, the holder was last paid in; 0
//! never), `[5..9] in_epoch u32 LE` (the 5 minutes of the last transfer into it, receipts aside),
//! `[9..17] in_amount u64 LE` (everything that arrived in it in `in_epoch`, receipts aside), the
//! rest zero.
//!
//! The hook's extra accounts, resolved here for the creator's transfers of the token (the sale, the
//! receipts), as its registry lists them: its state (writable), the mint's launch account
//! (read-only), the creator's holding of the mint (writable).
//!
//! The accounts a step takes after the companion's six (`[creator (signer), mint, launch,
//! companion, pool, state]`), looked up by key: the hook's state; the launch pool's swap accounts
//! as the launchpad's client builds a swap by the creator address, with the hook's slice (the DEX
//! program among them turns the sale on; without them nothing is sold); the token program and its
//! event authority; the bridged-SOL mint; the creator's holdings of the token and of bridged SOL;
//! then the holders: each one's holding of the token (writable: the receipt lands there) and of
//! bridged SOL (writable: the payment). The order among them does not matter.
//!
//! The state (`DividendState`, 128 bytes, Anchor's discriminator first; `create_script`'s
//! `state_len` is `STATE_LEN`): `[8] version u8`, `[9..41] mint`, `[41..45] epoch_secs u32`,
//! `[45..49] epoch u32`, `[49..57] epoch_budget u64`, `[57..65] paid_this_epoch u64`, `[65..73]
//! eligible_total_snapshot u64`, then totals (`paid_total`, `sold_total`, `receipts`, `steps`,
//! `epochs`), `[105..121] ref_price u128` (quote per base unit times 10^12; 0 until the first step
//! that moves it), `[121..125] ref_at u32` (unix time it last moved) and reserved zeros.
#![allow(unexpected_cfgs)]
use anchor_lang::prelude::*;
use anchor_lang::solana_program::instruction::Instruction;
use anchor_lang::solana_program::program::invoke;
use bordrless_launch::client as launch_client;
use bordrless_launch::state::Launch;
use bordrless_token::client as token_client;
use bordrless_token::client::Hook;
declare_id!("7jsNuZ3DUQ53r6F5gELzhSuntiYnexLSS45jsFUs41TX");
/// The Bordrless companion: the only program that can sign for a launch's creator address, so the
/// only caller of this script; it also makes the script's state and holds the pot.
pub const COMPANION_PROGRAM: Pubkey =
Pubkey::from_str_const("6ZUM1gWBH9hBBNoJoaVAGwSftyZ6CUda6vUZTW9MsJuo");
/// The Bordrless DEX: the owner of the launch pool whose reserves quote the sale.
pub const SWAP_PROGRAM: Pubkey =
Pubkey::from_str_const("GyzKSnnEu2uN5bBRecE4XYY2enbfR2D2MtxnbJPGy7hk");
/// The Bordrless token program: the owner of every holding.
pub const TOKEN_PROGRAM: Pubkey = bordrless_token::ID;
/// The companion's seed of a launch's creator address: `["creator", mint]`.
pub const CREATOR_SEED: &[u8] = b"creator";
/// The companion's seed of a launch's companion account: `["companion", mint]`.
pub const COMPANION_SEED: &[u8] = b"companion";
/// The companion's seed of a script's state: `["script-state", mint]`.
pub const STATE_SEED: &[u8] = b"script-state";
/// The hook's seed of its state for a mint: `["state", mint]` under the hook program.
pub const HOOK_STATE_SEED: &[u8] = b"state";
/// The layout of [`DividendState`].
pub const STATE_VERSION: u8 = 1;
/// The state's size: what a launch with this script asks `create_script` for.
pub const STATE_LEN: u32 = 128;
/// An epoch: 5 minutes, the hook's (the unit of a record's `paid_epoch` and `in_epoch`). The only
/// length: `init_data` is empty, or this written out.
pub const EPOCH_SECS: u32 = 300;
/// The record's layout version in a holding's hook data; 0 is nothing written.
pub const RECORD_VERSION: u8 = 1;
/// One whole token in base units (a launched token has 6 decimals).
pub const ONE_TOKEN: u64 = 1_000_000;
/// A wallet is paid while it holds less than this, 20M tokens (the hook counts it in
/// `eligible_total` then).
pub const ELIGIBLE_BELOW: u64 = 20_000_000 * ONE_TOKEN;
/// What the creator's token holding keeps back from a sale, for receipts (a million of them).
pub const RECEIPT_RESERVE: u64 = ONE_TOKEN;
/// The least a sale sells: a whole token (dust is not worth a swap).
pub const MIN_SALE: u64 = ONE_TOKEN;
/// What a sale accepts below its minimum out from the reference (after the band, its impact and the
/// fees): 2%.
pub const SLIPPAGE_BPS: u64 = 200;
/// The most holders one step pays.
pub const MAX_PAYOUTS: usize = 8;
/// The receipt: one base unit of the token, sent with every payment.
pub const RECEIPT_AMOUNT: u64 = 1;
/// Bordrless's share of a launch's creator fee, in basis points of it (the launch pool's share
/// model): a sale's quote allows for it.
pub const PROTOCOL_SHARE_BPS: u64 = 2_500;
pub const BPS: u64 = 10_000;
/// Prices are quote per base unit, times this (the companion's scale).
pub const PRICE_SCALE: u128 = 1_000_000_000_000;
/// The most a step sells: 0.5% of the pool's base side (real and virtual). Its price impact is then
/// under 1%, less than the fees of a sell and a buy back around it at any creator fee.
pub const SALE_POOL_SHARE_BPS: u64 = 50;
/// No sale while the pool's price is more than 3% below the reference price.
pub const SALE_BAND_BPS: u64 = 300;
/// The reference follows the pool's price by at most 2% a minute since it last moved...
pub const REF_BPS_PER_MINUTE: u64 = 200;
/// ... and by at most half at once.
pub const REF_MAX_MOVE_BPS: u64 = 5_000;
/// Where the hook keeps its records (its state's layout; the hook's source has the same table).
pub mod hook_state_offsets {
pub const VERSION: usize = 8;
pub const MINT: usize = 10;
pub const EPOCH_SECS: usize = 138;
pub const ELIGIBLE_TOTAL: usize = 142;
pub const LEN: usize = 198;
}
/// Where the companion keeps what this script reads (the `Companion` account's layout, Anchor's
/// discriminator first): the beneficiary, the launch's time, the dev bag and what of it was
/// released, the opening price (its buyback's reference, set at the launch), the pot.
pub mod companion_offsets {
pub const BENEFICIARY: usize = 43;
pub const LAUNCHED: usize = 107;
pub const LAUNCHED_AT: usize = 108;
pub const DEV_TOKENS: usize = 116;
pub const DEV_RELEASED: usize = 124;
pub const REFERENCE_PRICE: usize = 212;
pub const PENDING_POT: usize = 270;
pub const LEN: usize = 300;
}
/// Where the DEX keeps a pool's reserves (the `Pool` account's layout): the mints, then after the
/// hook's `Option<Pubkey>` the flags, the LP fee and the reserves.
pub mod pool_offsets {
pub const BASE_MINT: usize = 11;
pub const QUOTE_MINT: usize = 43;
pub const HOOK_OPTION: usize = 171;
}
fn u32_at(data: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?))
}
fn u64_at(data: &[u8], at: usize) -> Option<u64> {
Some(u64::from_le_bytes(data.get(at..at + 8)?.try_into().ok()?))
}
fn key_at(data: &[u8], at: usize) -> Option<Pubkey> {
Some(Pubkey::new_from_array(data.get(at..at + 32)?.try_into().ok()?))
}
/// What a step reads of the hook's state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HookStateView {
pub epoch_secs: u32,
pub eligible_total: u64,
}
/// Reads the hook's state from its account data: version 1, of `mint`. `None` for anything else.
pub fn parse_hook_state(data: &[u8], mint: &Pubkey) -> Option<HookStateView> {
use hook_state_offsets as o;
if data.len() < o::LEN || data[o::VERSION] != 1 || key_at(data, o::MINT)? != *mint {
return None;
}
Some(HookStateView {
epoch_secs: u32_at(data, o::EPOCH_SECS)?,
eligible_total: u64_at(data, o::ELIGIBLE_TOTAL)?,
})
}
/// What a step reads of the companion.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CompanionView {
pub beneficiary: Pubkey,
pub launched: bool,
pub launched_at: i64,
pub dev_tokens: u64,
pub dev_released: u64,
/// The opening price (quote per base unit times `PRICE_SCALE`), set at the launch.
pub reference_price: u128,
pub pending_pot: u64,
}
/// Reads the companion from its account data. `None` for data too short.
pub fn parse_companion(data: &[u8]) -> Option<CompanionView> {
use companion_offsets as o;
if data.len() < o::LEN {
return None;
}
Some(CompanionView {
beneficiary: key_at(data, o::BENEFICIARY)?,
launched: *data.get(o::LAUNCHED)? == 1,
launched_at: i64::from_le_bytes(data.get(o::LAUNCHED_AT..o::LAUNCHED_AT + 8)?.try_into().ok()?),
dev_tokens: u64_at(data, o::DEV_TOKENS)?,
dev_released: u64_at(data, o::DEV_RELEASED)?,
reference_price: u128::from_le_bytes(
data.get(o::REFERENCE_PRICE..o::REFERENCE_PRICE + 16)?.try_into().ok()?,
),
pending_pot: u64_at(data, o::PENDING_POT)?,
})
}
/// A launch pool's reserves, as a sale is quoted on them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PoolView {
pub base_mint: Pubkey,
pub quote_mint: Pubkey,
pub lp_fee_bps: u16,
pub base_reserve: u64,
pub quote_reserve: u64,
pub virtual_base: u64,
pub virtual_quote: u64,
}
/// Reads a pool from its account data. `None` for data too short or a bad option tag.
pub fn parse_pool(data: &[u8]) -> Option<PoolView> {
use pool_offsets as o;
let base_mint = key_at(data, o::BASE_MINT)?;
let quote_mint = key_at(data, o::QUOTE_MINT)?;
let mut at = o::HOOK_OPTION;
match *data.get(at)? {
0 => at += 1,
1 => at += 33,
_ => return None,
}
let lp_fee_bps = u16::from_le_bytes(data.get(at + 2..at + 4)?.try_into().ok()?);
at += 6;
Some(PoolView {
base_mint,
quote_mint,
lp_fee_bps,
base_reserve: u64_at(data, at)?,
quote_reserve: u64_at(data, at + 8)?,
virtual_base: u64_at(data, at + 16)?,
virtual_quote: u64_at(data, at + 24)?,
})
}
/// The pool's price: quote per base unit (real and virtual reserves), times `PRICE_SCALE`; `None`
/// for an empty base side.
pub fn spot_price(pool: &PoolView) -> Option<u128> {
let quote = u128::from(pool.quote_reserve) + u128::from(pool.virtual_quote);
let base = u128::from(pool.base_reserve) + u128::from(pool.virtual_base);
(base > 0).then(|| quote * PRICE_SCALE / base)
}
/// The most one step sells: `SALE_POOL_SHARE_BPS` of the pool's base side (real and virtual).
pub fn sale_cap(pool: &PoolView) -> u64 {
let base = u128::from(pool.base_reserve) + u128::from(pool.virtual_base);
u64::try_from(base * u128::from(SALE_POOL_SHARE_BPS) / u128::from(BPS)).unwrap_or(u64::MAX)
}
/// What the curve gives for selling `amount` now (constant product over the real and virtual
/// reserves, rounded down); `None` when the pool's real quote can't fill it.
pub fn curve_out(pool: &PoolView, amount: u64) -> Option<u64> {
let x = u128::from(pool.base_reserve) + u128::from(pool.virtual_base);
let y = u128::from(pool.quote_reserve) + u128::from(pool.virtual_quote);
let dx = u128::from(amount);
let out = dx.checked_mul(y)? / x.checked_add(dx)?;
(out > 0 && out <= u128::from(pool.quote_reserve)).then_some(out as u64)
}
/// The LP fee a launch pool charges at `now` (the sniper schedule, falling from the start rate to
/// the launch's own over its window), as the launchpad sets it for anyone's sale.
pub fn lp_fee_now(launch: &Launch, now: i64) -> u16 {
let (base, start, window) = (launch.lp_fee_bps, launch.sniper_start_bps, launch.sniper_window_secs);
if window <= 0 || now >= launch.created_at.saturating_add(window) || start <= base {
return base;
}
let elapsed = now.saturating_sub(launch.created_at).max(0) as u64;
let span = u64::from(start - base);
let fallen = span.saturating_mul(elapsed) / (window as u64);
start - (fallen.min(span) as u16)
}
/// `reference` moved toward `spot` by at most `REF_BPS_PER_MINUTE` a minute over `elapsed` seconds,
/// and by at most `REF_MAX_MOVE_BPS` at once; `spot` itself when there is no reference yet.
pub fn step_reference(reference: u128, spot: u128, elapsed: i64) -> u128 {
if reference == 0 {
return spot;
}
let bps = (u128::from(elapsed.max(0) as u64) * u128::from(REF_BPS_PER_MINUTE) / 60)
.min(u128::from(REF_MAX_MOVE_BPS));
let low = reference * (u128::from(BPS) - bps) / u128::from(BPS);
let high = reference.saturating_mul(u128::from(BPS) + bps) / u128::from(BPS);
spot.clamp(low, high)
}
/// The lowest price a sale may meet: the reference less `SALE_BAND_BPS`.
pub fn sale_floor(reference: u128) -> u128 {
reference * u128::from(BPS - SALE_BAND_BPS) / u128::from(BPS)
}
/// The least bridged SOL a sale of `amount` tokens accepts, from a price no transaction can move:
/// `amount` at `floor` (the reference less `SALE_BAND_BPS`), less the sale's own impact (at most
/// `SALE_POOL_SHARE_BPS`, the most a step sells), the LP fee in force, the creator fee and
/// Bordrless's share of it, and `SLIPPAGE_BPS`. `None` when nothing would come out.
pub fn sale_min_out(floor: u128, lp_fee_bps: u16, creator_fee_bps: u16, amount: u64) -> Option<u64> {
if amount == 0 || floor == 0 {
return None;
}
let gross = u128::from(amount).checked_mul(floor)? / PRICE_SCALE;
let gross = gross * u128::from(BPS - SALE_POOL_SHARE_BPS) / u128::from(BPS);
let fees = u128::from(lp_fee_bps)
+ u128::from(creator_fee_bps)
+ u128::from(creator_fee_bps).div_ceil(u128::from(BPS / PROTOCOL_SHARE_BPS));
let kept = u128::from(BPS).checked_sub(fees)?;
let expected = gross * kept / u128::from(BPS);
let min_out = expected * (u128::from(BPS) - u128::from(SLIPPAGE_BPS)) / u128::from(BPS);
let min_out = u64::try_from(min_out).ok()?;
(min_out > 0).then_some(min_out)
}
/// A holder's share of an epoch: `balance * budget / total`, rounded down; 0 without a total.
pub fn share_of(balance: u64, budget: u64, total: u64) -> u64 {
if total == 0 {
return 0;
}
let share = u128::from(balance) * u128::from(budget) / u128::from(total);
u64::try_from(share).unwrap_or(u64::MAX)
}
/// The epoch of unix time `now` with epochs of `epoch_secs`: `floor(now / epoch_secs)`.
pub fn epoch_of(now: i64, epoch_secs: u32) -> u32 {
if epoch_secs == 0 || now <= 0 {
return 0;
}
u32::try_from((now as u64) / u64::from(epoch_secs)).unwrap_or(u32::MAX)
}
/// The epoch a holding's receipt says it was paid in; `None` without a receipt.
pub fn receipt_epoch(hook_data: &[u8; 64]) -> Option<u32> {
if hook_data[0] != RECORD_VERSION {
return None;
}
let paid = u32::from_le_bytes([hook_data[1], hook_data[2], hook_data[3], hook_data[4]]);
(paid != 0).then_some(paid)
}
/// What arrived in a holding during `epoch`, from its record (receipts aside): `in_amount` when
/// `in_epoch` is that epoch, else 0.
pub fn received_in(hook_data: &[u8; 64], epoch: u32) -> u64 {
if hook_data[0] != RECORD_VERSION {
return 0;
}
let in_epoch = u32::from_le_bytes([hook_data[5], hook_data[6], hook_data[7], hook_data[8]]);
if in_epoch != epoch {
return 0;
}
let mut amount = [0u8; 8];
amount.copy_from_slice(&hook_data[9..17]);
u64::from_le_bytes(amount)
}
/// What a holding may be paid for in `epoch`: what it held before the epoch, its balance less all
/// that arrived in it during the epoch (what it sent since counts against what it held), at least 0.
pub fn held_before(balance: u64, hook_data: &[u8; 64], epoch: u32) -> u64 {
balance.saturating_sub(received_in(hook_data, epoch))
}
/// The epoch length `init_data` asks for: empty, or 300 as a u32 little-endian number of seconds
/// (the only length). `None` for anything else.
pub fn epoch_secs_of(init_data: &[u8]) -> Option<u32> {
match init_data.len() {
0 => Some(EPOCH_SECS),
4 => (u32::from_le_bytes(init_data.try_into().ok()?) == EPOCH_SECS).then_some(EPOCH_SECS),
_ => None,
}
}
/// Whether `owner` is a wallet the script pays: not the launch, its pool or the creator address,
/// not the default key, and on the ed25519 curve (no program-owned address: the hook counts the
/// same wallets in `eligible_total`).
pub fn eligible(owner: &Pubkey, excluded: &[Pubkey]) -> bool {
*owner != Pubkey::default() && !excluded.contains(owner) && owner.is_on_curve()
}
/// The hook's extra accounts for a transfer of the token by the creator address, as the hook's
/// registry lists them: its state (writable), the launch (read-only), the creator's holding of the
/// mint (writable).
pub fn hook_extras(hook_state: Pubkey, launch: Pubkey, creator_holding: Pubkey) -> Vec<AccountMeta> {
vec![
AccountMeta::new(hook_state, false),
AccountMeta::new_readonly(launch, false),
AccountMeta::new(creator_holding, false),
]
}
fn find<'a, 'info>(accounts: &'a [AccountInfo<'info>], key: &Pubkey) -> Option<&'a AccountInfo<'info>> {
accounts.iter().find(|a| a.key == key)
}
/// The balance of the holding at `key` among `accounts`: 0 when it is not there or does not exist.
fn balance_of(accounts: &[AccountInfo], key: &Pubkey) -> u64 {
match find(accounts, key) {
Some(info) if *info.owner == TOKEN_PROGRAM => {
token_client::read_holding(info).map(|h| h.amount).unwrap_or(0)
}
_ => 0,
}
}
#[program]
pub mod whale_bait_dividend {
use super::*;
/// Once, before the launch (the companion's `create_script`): the state's header, its epoch 5
/// minutes (`init_data`: empty, or 300 as a u32 little-endian number of seconds; anything else
/// is refused).
pub fn companion_init<'info>(
ctx: Context<'info, CompanionInit<'info>>,
mint: Pubkey,
init_data: Vec<u8>,
) -> Result<()> {
require_keys_eq!(mint, ctx.accounts.mint.key(), ScriptError::WrongMint);
let epoch_secs = epoch_secs_of(&init_data).ok_or(ScriptError::BadInitData)?;
let s = &mut ctx.accounts.state;
s.version = STATE_VERSION;
s.mint = mint;
s.epoch_secs = epoch_secs;
s.epoch = 0;
s.epoch_budget = 0;
s.paid_this_epoch = 0;
s.eligible_total_snapshot = 0;
s.paid_total = 0;
s.sold_total = 0;
s.receipts = 0;
s.steps = 0;
s.epochs = 0;
s.ref_price = 0;
s.ref_at = 0;
s.reserved = [0; 3];
Ok(())
}
/// Every step (the companion's `script_step`): the sale of the tokens above the dev bag, the
/// epoch rolled when due, the holders passed paid their share with a receipt each.
pub fn companion_step<'info>(
ctx: Context<'info, CompanionStep<'info>>,
mint: Pubkey,
) -> Result<()> {
require_keys_eq!(mint, ctx.accounts.mint.key(), ScriptError::WrongMint);
let now = Clock::get()?.unix_timestamp;
let launch: Launch = launch_client::read_launch(&ctx.accounts.launch)?;
require_keys_eq!(launch.mint, mint, ScriptError::WrongMint);
let launch_key = ctx.accounts.launch.key();
require_keys_eq!(launch.pool, ctx.accounts.pool.key(), ScriptError::WrongAccount);
require_keys_eq!(*ctx.accounts.pool.owner, SWAP_PROGRAM, ScriptError::WrongAccount);
let pool = {
let data = ctx.accounts.pool.try_borrow_data()?;
parse_pool(&data).ok_or(ScriptError::WrongAccount)?
};
require_keys_eq!(pool.base_mint, mint, ScriptError::WrongAccount);
let quote = launch.quote_mint;
require_keys_eq!(pool.quote_mint, quote, ScriptError::WrongAccount);
// The companion: the pot and the dev bag.
require_keys_eq!(
ctx.accounts.companion.key(),
Pubkey::find_program_address(&[COMPANION_SEED, mint.as_ref()], &COMPANION_PROGRAM).0,
ScriptError::WrongAccount
);
require_keys_eq!(*ctx.accounts.companion.owner, COMPANION_PROGRAM, ScriptError::WrongAccount);
let companion = {
let data = ctx.accounts.companion.try_borrow_data()?;
parse_companion(&data).ok_or(ScriptError::WrongAccount)?
};
require!(companion.launched, ScriptError::WrongAccount);
// The hook the mint names, and its state among the accounts.
let hook = token_client::read_mint(&ctx.accounts.mint)?
.hook_program
.ok_or(ScriptError::NoHook)?;
require!(launch.custom_hook == Some(hook), ScriptError::NoHook);
let remaining = ctx.remaining_accounts;
let hook_state_key = Pubkey::find_program_address(&[HOOK_STATE_SEED, mint.as_ref()], &hook).0;
let hook_state_info = find(remaining, &hook_state_key).ok_or(ScriptError::NoHookState)?;
require_keys_eq!(*hook_state_info.owner, hook, ScriptError::NoHookState);
let hook_state = {
let data = hook_state_info.try_borrow_data()?;
parse_hook_state(&data, &mint).ok_or(ScriptError::NoHookState)?
};
let creator = ctx.accounts.creator.key();
let creator_tokens = token_client::holding_address(&mint, &creator);
let creator_quote = token_client::holding_address("e, &creator);
let extras = hook_extras(hook_state_key, launch_key, creator_tokens);
let hook_slice = launch_client::custom_hook_slice(hook, extras.clone());
let hook_for_transfers = Hook::of(hook);
let mut all: Vec<AccountInfo<'info>> = ctx.accounts.to_account_infos();
all.extend_from_slice(remaining);
let s = &mut ctx.accounts.state;
require!(s.version == STATE_VERSION && s.mint == mint, ScriptError::NotInitialized);
let mut did_something = false;
// 1. The sale: what the creator holds above the dev bag still vesting, less the receipts'
// reserve, sold on the launch pool when the sender passed the swap's accounts. Steps are
// anyone's, so a sale is judged by a reference price no transaction can move (the state's,
// or the companion's opening price at first), never sells into a price more than
// `SALE_BAND_BPS` below it, takes its minimum out from it, sells at most
// `SALE_POOL_SHARE_BPS` of the pool's base side, and waits out a sniper fee.
let dev_bag = companion.dev_tokens.saturating_sub(companion.dev_released);
let mut tokens = balance_of(&all, &creator_tokens);
let surplus = tokens.saturating_sub(tokens.min(dev_bag));
let sale = surplus.saturating_sub(RECEIPT_RESERVE).min(sale_cap(&pool));
let spot = spot_price(&pool);
let (reference, reference_at) = if s.ref_price == 0 {
(companion.reference_price, companion.launched_at)
} else {
(s.ref_price, i64::from(s.ref_at))
};
let floor = sale_floor(reference);
let lp_fee = lp_fee_now(&launch, now);
let sellable = sale >= MIN_SALE
&& find(remaining, &SWAP_PROGRAM).is_some()
&& lp_fee <= launch.lp_fee_bps
&& floor > 0
&& spot.is_some_and(|p| p >= floor)
&& curve_out(&pool, sale).is_some();
if sellable {
if let Some(min_out) = sale_min_out(floor, lp_fee, launch.creator_fee_bps, sale) {
let keys = launch_client::LaunchKeys::of(&launch);
let ix: Instruction = launch_client::swap_with_base_slice(
&keys,
creator,
creator,
0,
sale,
min_out,
hook_slice.clone(),
);
invoke(&ix, &all)?;
tokens = tokens.saturating_sub(sale);
s.sold_total = s.sold_total.saturating_add(sale);
did_something = true;
}
}
// The reference follows the pool's price as this step found it (before its sale), by at
// most `REF_BPS_PER_MINUTE` a minute since it last moved: what a transaction does to the
// price moves it by no more than the time since the last step allows.
if let Some(p) = spot {
s.ref_price = step_reference(reference, p, now.saturating_sub(reference_at));
s.ref_at = u32::try_from(now.max(0)).unwrap_or(u32::MAX);
}
// 2. The epoch (the hook's 5 minutes): its budget is the pot now, its denominator the hook's
// eligible total now.
let epoch = epoch_of(now, EPOCH_SECS);
if epoch > s.epoch {
s.epoch = epoch;
s.epoch_budget = companion.pending_pot;
s.eligible_total_snapshot = hook_state.eligible_total;
s.paid_this_epoch = 0;
s.epochs = s.epochs.saturating_add(1);
did_something = true;
}
// 3. The payouts: never beyond the epoch's budget, nor beyond the pot as it stands; each
// with a receipt from what the creator holds above the dev bag; each for what the holder
// held before the epoch (what arrived since counts from the next: tokens paid here cannot
// be sent on and paid again). A share is pro rata against the epoch's snapshot, or against
// the total as it stands when that is larger; the tokens that cannot be paid this epoch
// stay in it, so their shares stay in the pot.
let denominator = s.eligible_total_snapshot.max(hook_state.eligible_total);
let mut budget_left = s
.epoch_budget
.saturating_sub(s.paid_this_epoch)
.min(companion.pending_pot);
let mut receipts_left = tokens.saturating_sub(tokens.min(dev_bag));
let excluded = [launch_key, launch.pool, creator];
let mut paid = 0usize;
for info in remaining.iter() {
if paid >= MAX_PAYOUTS || budget_left == 0 || receipts_left == 0 {
break;
}
if *info.owner != TOKEN_PROGRAM || *info.key == creator_tokens {
continue;
}
let Ok(h) = token_client::read_holding(info) else {
continue;
};
if h.mint != mint
|| h.amount == 0
|| h.amount >= ELIGIBLE_BELOW
|| !eligible(&h.owner, &excluded)
|| receipt_epoch(&h.hook_data) == Some(epoch)
{
continue;
}
let held = held_before(h.amount, &h.hook_data, epoch);
if held == 0 {
continue;
}
let pay = share_of(held, s.epoch_budget, denominator).min(budget_left);
if pay == 0 {
continue;
}
let holder_quote = token_client::holding_address("e, &h.owner);
match find(remaining, &holder_quote) {
Some(hq) if *hq.owner == TOKEN_PROGRAM => {}
_ => continue,
}
let payment: Instruction =
token_client::transfer_with(creator, creator_quote, holder_quote, quote, None, vec![], pay);
invoke(&payment, &all)?;
let receipt: Instruction = token_client::transfer_with(
creator,
creator_tokens,
*info.key,
mint,
Some(hook_for_transfers),
extras.clone(),
RECEIPT_AMOUNT,
);
invoke(&receipt, &all)?;
budget_left -= pay;
receipts_left -= 1;
paid += 1;
s.paid_this_epoch = s.paid_this_epoch.saturating_add(pay);
s.paid_total = s.paid_total.saturating_add(pay);
s.receipts = s.receipts.saturating_add(1);
did_something = true;
}
require!(did_something, ScriptError::NothingToDo);
s.steps = s.steps.saturating_add(1);
Ok(())
}
}
/// This script's state for a mint, after Anchor's discriminator (128 bytes in all; the offsets are
/// in the module comment).
#[account]
#[derive(InitSpace)]
pub struct DividendState {
/// `STATE_VERSION`.
pub version: u8,
/// The mint this state is for.
pub mint: Pubkey,
/// A dividend epoch, seconds: 300 (what the keeper's round reads).
pub epoch_secs: u32,
/// The epoch of the last step (0 before the first).
pub epoch: u32,
/// The pot at the epoch's first step: what the epoch may pay.
pub epoch_budget: u64,
/// Paid so far this epoch.
pub paid_this_epoch: u64,
/// The hook's `eligible_total` at the epoch's first step: the shares' denominator.
pub eligible_total_snapshot: u64,
/// Paid all time, lamports of bridged SOL.
pub paid_total: u64,
/// Tokens sold all time.
pub sold_total: u64,
/// Receipts sent all time.
pub receipts: u64,
/// Steps that did something.
pub steps: u32,
/// Epochs rolled.
pub epochs: u32,
/// The reference price a sale is judged by (quote per base unit times `PRICE_SCALE`); 0 until
/// the first step moves it (the companion's opening price stands in until then).
pub ref_price: u128,
/// Unix time the reference last moved.
pub ref_at: u32,
/// Reserved, zero.
pub reserved: [u8; 3],
}
impl DividendState {
/// Account size.
pub const LEN: usize = 8 + Self::INIT_SPACE;
}
/// Accounts of `companion_init`, as the companion passes them; then every account the launcher's
/// transaction passed (`ctx.remaining_accounts`), none of them signing.
#[derive(Accounts)]
pub struct CompanionInit<'info> {
/// The launch's creator address, `PDA(["creator", mint])` under the companion. Only the
/// companion can sign for it: this check is what makes the caller the companion.
#[account(mut, seeds = [CREATOR_SEED, mint.key().as_ref()], bump, seeds::program = COMPANION_PROGRAM)]
pub creator: Signer<'info>,
/// CHECK: the launch's mint; it does not exist yet (the creator address is derived from it).
pub mint: UncheckedAccount<'info>,
/// This script's state for the mint, as the companion made it: Anchor's `zero` takes it only
/// while its discriminator is still zeros (so once), owned by this program, writable and rent
/// exempt, and `seeds` that it is the companion's `["script-state", mint]`.
#[account(zero, seeds = [STATE_SEED, mint.key().as_ref()], bump, seeds::program = COMPANION_PROGRAM)]
pub state: Account<'info, DividendState>,
pub system_program: Program<'info, System>,
}
/// Accounts of `companion_step`, as the companion passes them (it checked the mint, the launch and
/// the pool); then every account the sender passed (`ctx.remaining_accounts`), none of them
/// signing: what the module comment lists.
#[derive(Accounts)]
pub struct CompanionStep<'info> {
/// The launch's creator address (as in `companion_init`): it holds the pot and the tokens, and
/// signs this call.
#[account(mut, seeds = [CREATOR_SEED, mint.key().as_ref()], bump, seeds::program = COMPANION_PROGRAM)]
pub creator: Signer<'info>,
/// CHECK: the token (read in the handler as the token program's mint: its hook program).
pub mint: UncheckedAccount<'info>,
/// CHECK: the launch, `PDA(["launch", mint])` under the launchpad (read in the handler with
/// `bordrless_launch::client::read_launch`: owner-checked, of the mint).
pub launch: UncheckedAccount<'info>,
/// CHECK: the companion, `PDA(["companion", mint])` under the companion program (address- and
/// owner-checked in the handler; the pot is its `pending_pot`).
pub companion: UncheckedAccount<'info>,
/// CHECK: the launch's pool on the DEX (the launch's, owner-checked in the handler).
pub pool: UncheckedAccount<'info>,
/// This script's state for the mint, as `companion_init` left it.
#[account(mut, seeds = [STATE_SEED, mint.key().as_ref()], bump, seeds::program = COMPANION_PROGRAM)]
pub state: Account<'info, DividendState>,
}
/// Errors.
#[error_code]
pub enum ScriptError {
#[msg("the mint in the instruction is not the mint account")]
WrongMint,
#[msg("an account is not the one expected")]
WrongAccount,
#[msg("the init data is not the epoch: empty, or 300 as a u32 of seconds")]
BadInitData,
#[msg("the state was never initialized")]
NotInitialized,
#[msg("the mint has no custom hook, or not the launch's")]
NoHook,
#[msg("the hook's state for the mint was not passed, or is not the hook's")]
NoHookState,
#[msg("nothing to sell, no epoch to roll and nobody to pay in this step")]
NothingToDo,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_state_is_128_bytes_at_its_offsets() {
let s = DividendState {
version: STATE_VERSION,
mint: Pubkey::new_unique(),
epoch_secs: 300,
epoch: 5,
epoch_budget: 6,
paid_this_epoch: 7,
eligible_total_snapshot: 8,
paid_total: 9,
sold_total: 10,
receipts: 11,
steps: 12,
epochs: 13,
ref_price: 14,
ref_at: 15,
reserved: [0; 3],
};
let mut data = Vec::new();
s.try_serialize(&mut data).unwrap();
assert_eq!(data.len(), DividendState::LEN);
assert_eq!(DividendState::LEN, STATE_LEN as usize);
assert_eq!(data[8], 1);
assert_eq!(&data[9..41], s.mint.as_ref());
assert_eq!(&data[41..45], &300u32.to_le_bytes());
assert_eq!(&data[45..49], &5u32.to_le_bytes());
assert_eq!(&data[49..57], &6u64.to_le_bytes());
assert_eq!(&data[57..65], &7u64.to_le_bytes());
assert_eq!(&data[65..73], &8u64.to_le_bytes());
assert_eq!(&data[73..81], &9u64.to_le_bytes());
assert_eq!(&data[81..89], &10u64.to_le_bytes());
assert_eq!(&data[89..97], &11u64.to_le_bytes());
assert_eq!(&data[97..101], &12u32.to_le_bytes());
assert_eq!(&data[101..105], &13u32.to_le_bytes());
assert_eq!(&data[105..121], &14u128.to_le_bytes());
assert_eq!(&data[121..125], &15u32.to_le_bytes());
assert_eq!(&data[125..], &[0u8; 3][..]);
}
#[test]
fn the_init_data_is_empty_or_five_minutes() {
assert_eq!(EPOCH_SECS, 300);
assert_eq!(epoch_secs_of(&[]), Some(300));
assert_eq!(epoch_secs_of(&300u32.to_le_bytes()), Some(300));
for bad in [0u32, 1, 299, 301, 600, 900, 3_600, 86_400, u32::MAX] {
assert_eq!(epoch_secs_of(&bad.to_le_bytes()), None, "{bad}");
}
assert_eq!(epoch_secs_of(&[1, 2, 3]), None);
assert_eq!(epoch_secs_of(&[0; 5]), None);
}
/// The hook's record: version 1, `paid_epoch` (u32 LE), `in_epoch` (u32 LE), `in_amount` (u64
/// LE).
fn record(paid_epoch: u32, in_epoch: u32, in_amount: u64) -> [u8; 64] {
let mut data = [0u8; 64];
data[0] = 1;
data[1..5].copy_from_slice(&paid_epoch.to_le_bytes());
data[5..9].copy_from_slice(&in_epoch.to_le_bytes());
data[9..17].copy_from_slice(&in_amount.to_le_bytes());
data
}
#[test]
fn the_record_says_who_was_paid_and_what_arrived_this_epoch() {
let e = 6_000_084;
assert_eq!(epoch_of(1_800_025_200, EPOCH_SECS), e);
assert_eq!(epoch_of(-1, 300), 0);
assert_eq!(epoch_of(10, 0), 0);
// The receipt: the epoch paid in; none when never paid or nothing written.
assert_eq!(receipt_epoch(&[0; 64]), None);
assert_eq!(receipt_epoch(&record(0, e, 5)), None);
assert_eq!(receipt_epoch(&record(e, 0, 0)), Some(e));
let mut other = record(e, e, 5);
other[0] = 2;
assert_eq!((receipt_epoch(&other), received_in(&other, e)), (None, 0));
// What arrived: this epoch's amount, nothing for an earlier epoch's.
assert_eq!(received_in(&record(e - 1, e, 70), e), 70);
assert_eq!(received_in(&record(0, e - 1, 70), e), 0);
// Paid for the balance less that: a holder sent one base unit, for the rest; one that keeps
// buying, for what it had; a fresh wallet, nothing; tokens it sent count against what it
// held (balance 50 after 70 arrived this epoch: nothing).
assert_eq!(held_before(1_000, &record(0, e, 1), e), 999);
assert_eq!(held_before(1_500, &record(e - 1, e, 500), e), 1_000);
assert_eq!(held_before(700, &record(0, e, 700), e), 0);
assert_eq!(held_before(50, &record(0, e, 70), e), 0);
assert_eq!(held_before(1_000, &record(0, e - 1, 1_000), e), 1_000);
assert_eq!(held_before(1_000, &[0; 64], e), 1_000);
// The next epoch everything is held from before it.
assert_eq!(held_before(1_500, &record(0, e, 500), e + 1), 1_500);
}
#[test]
fn shares_and_the_sale_quote() {
assert_eq!(share_of(1_000, 500, 10_000), 50);
assert_eq!(share_of(1_000, 500, 0), 0);
assert_eq!(share_of(u64::MAX, u64::MAX, 1), u64::MAX);
assert_eq!(share_of(3, 10, 7), 4);
let pool = PoolView {
base_mint: Pubkey::default(),
quote_mint: Pubkey::default(),
lp_fee_bps: 30,
base_reserve: 700_000_000_000_000,
quote_reserve: 2_000_000_000,
virtual_base: 50_000_000_000_000,
virtual_quote: 28_125_000_000,
};
// The price: 30.125e9 lamports over 750e12 base units, times 1e12.
assert_eq!(spot_price(&pool), Some(30_125_000_000u128 * PRICE_SCALE / 750_000_000_000_000));
// A step sells at most 0.5% of the base side: 3.75e12 base units.
assert_eq!(sale_cap(&pool), 3_750_000_000_000);
// The curve, and none past the pool's real quote.
assert_eq!(curve_out(&pool, 1_000_000_000_000), Some(40_113_182));
let dry = PoolView {
quote_reserve: 1_000,
..pool
};
assert_eq!(curve_out(&dry, 1_000_000_000_000), None);
// The minimum out of 1e12 base units at a reference of 40,000 (times 1e12): less the 3%
// band, the 0.5% impact, 0.3% + 2% + Bordrless's quarter of it (0.5%), and 2% slippage.
let floor = sale_floor(40_000);
assert_eq!(floor, 38_800);
let gross = 1_000_000_000_000u128 * 38_800 / PRICE_SCALE * (10_000 - 50) / 10_000;
let expected = gross * (10_000 - 30 - 200 - 50) / 10_000 * (10_000 - 200) / 10_000;
assert_eq!(sale_min_out(floor, 30, 200, 1_000_000_000_000), Some(expected as u64));
// At the price the reference allows, the curve gives at least that minimum.
assert!(u128::from(curve_out(&pool, 1_000_000_000_000).unwrap()) * (10_000 - 30 - 200 - 50) / 10_000 >= expected);
assert_eq!(sale_min_out(floor, 30, 200, 0), None);
assert_eq!(sale_min_out(0, 30, 200, 1_000_000_000_000), None);
assert_eq!(sale_min_out(floor, 30, 200, 1), None);
}
#[test]
fn the_reference_moves_a_bounded_amount_and_the_sniper_fee_is_the_one_in_force() {
// 2% a minute since it last moved, at most half at once, toward the price either way.
assert_eq!(step_reference(0, 777, 0), 777);
assert_eq!(step_reference(10_000, 20_000, 60), 10_200);
assert_eq!(step_reference(10_000, 5_000, 60), 9_800);
assert_eq!(step_reference(10_000, 10_100, 60), 10_100);
assert_eq!(step_reference(10_000, 20_000, 30), 10_100);
assert_eq!(step_reference(10_000, 20_000, 0), 10_000);
assert_eq!(step_reference(10_000, 90_000, 86_400), 15_000);
assert_eq!(step_reference(10_000, 1, 86_400), 5_000);
assert_eq!(step_reference(10_000, 20_000, -5), 10_000);
// The LP fee in force: the sniper schedule falls from its start to the launch's own over
// its window; after the window, the launch's own.
let mut launch: Launch = Launch::try_deserialize_unchecked(&mut &[0u8; 1_024][..]).unwrap();
launch.lp_fee_bps = 30;
launch.sniper_start_bps = 8_000;
launch.sniper_window_secs = 600;
launch.created_at = 1_000;
assert_eq!(lp_fee_now(&launch, 1_000), 8_000);
assert_eq!(lp_fee_now(&launch, 1_300), 8_000 - (7_970 * 300 / 600) as u16);
assert_eq!(lp_fee_now(&launch, 1_600), 30);
assert_eq!(lp_fee_now(&launch, 99_999), 30);
launch.sniper_window_secs = 0;
assert_eq!(lp_fee_now(&launch, 1_000), 30);
}
#[test]
fn the_companion_names_this_scripts_calls_as_anchor_does() {
use anchor_lang::Discriminator;
assert_eq!(
crate::instruction::CompanionInit::DISCRIMINATOR,
&[0x76, 0xa1, 0xbb, 0xb4, 0x57, 0x56, 0xdc, 0x88][..]
);
assert_eq!(
crate::instruction::CompanionStep::DISCRIMINATOR,
&[0x5e, 0x15, 0xaa, 0xa1, 0xf6, 0x6e, 0x15, 0x28][..]
);
}
#[test]
fn the_hook_state_and_the_companion_read_at_their_offsets() {
let mint = Pubkey::new_unique();
let mut data = vec![0u8; hook_state_offsets::LEN];
data[8] = 1;
data[10..42].copy_from_slice(mint.as_ref());
data[138..142].copy_from_slice(&300u32.to_le_bytes());
data[142..150].copy_from_slice(&77u64.to_le_bytes());
assert_eq!(
parse_hook_state(&data, &mint),
Some(HookStateView {
epoch_secs: 300,
eligible_total: 77
})
);
assert_eq!(parse_hook_state(&data, &Pubkey::new_unique()), None);
data[8] = 2;
assert_eq!(parse_hook_state(&data, &mint), None);
assert_eq!(parse_hook_state(&data[..100], &mint), None);
let beneficiary = Pubkey::new_unique();
let mut c = vec![0u8; companion_offsets::LEN];
c[43..75].copy_from_slice(beneficiary.as_ref());
c[107] = 1;
c[108..116].copy_from_slice(&1_800_000_000i64.to_le_bytes());
c[116..124].copy_from_slice(&5u64.to_le_bytes());
c[124..132].copy_from_slice(&2u64.to_le_bytes());
c[212..228].copy_from_slice(&25_000u128.to_le_bytes());
c[270..278].copy_from_slice(&9u64.to_le_bytes());
assert_eq!(
parse_companion(&c),
Some(CompanionView {
beneficiary,
launched: true,
launched_at: 1_800_000_000,
dev_tokens: 5,
dev_released: 2,
reference_price: 25_000,
pending_pot: 9
})
);
assert_eq!(parse_companion(&c[..200]), None);
}
}
keeper.json20 lines ›
{
"version": 1,
"holdings": {
"minBalance": "1",
"maxBalance": "19999999999999",
"hookData": {
"offset": 1,
"width": 4,
"compare": "ne",
"against": { "epoch": { "secs": 300, "fieldUnitSecs": null } }
},
"payTo": "ownerQuoteHolding",
"perStep": { "withSwapAccounts": 3, "withoutSwapAccounts": null }
},
"swapAccounts": true,
"hookAccounts": false,
"payouts": false,
"roundSecs": 300
}