bordrfull
← Workshop
Hook pairApproved and deployedPassed with notes

Tenure Pot

Pays holders by tokens times time held

A trade hook and a fee hook that work together.

Launch config comingSubmit yours ↗

What it does

Bordrless’s reading of the source, written 14 Oct, 09:49 UTC. Read by AI from the code, not an audit.

Takes nothing from trades. Records when each holder's tokens arrived, and pays 90% of the pot in SOL to wallets by tokens held times time held.

The hook takes no cut and refuses no transfer. It stamps each holding with the average time its tokens arrived, capped at 30 days. Tokens received count as arriving now. Once an hour, if the pot holds at least 0.15 SOL, the script opens a round with 90% of the pot (at most 1,000 SOL). It pays each wallet of 100,000+ tokens, once a round, SOL in proportion to tokens times seconds held. The companion address, program-owned holders and the pool are never paid. Shares under budget/1,264 are skipped.

The most it takes

0%

0% of buys, sells and transfers

Largest cut in the trading test0%
Builder’s share of the creator fee0%
Launches with it0

Every rule

When it applies, what it takes, and where that goes.

OnWhenWhat happensAt mostTo
BuysBuys0% hook tax; the tokens bought count as arriving now0%—
SellsSells0% hook tax; the age of the tokens kept stays as it was0%—
TransfersTransfersfree; tokens received count as arriving now and lower the recipient's average age0%—
Each stepA round, at most once an hourOnce the pot holds 0.15 SOL: 90% of the pot, at most 1,000 SOL, set aside for holders90%Holders
Each stepEach step, every 15 secondsUp to 16 wallets of 100,000+ tokens, paid budget × tokens × seconds held (30 days max) / total90%Holders
Each stepSmall sharesA share under budget/1,264 (at least 0.0001 SOL) is not paid that round0%—
Each stepRound endEnds at 1 hour once 90% is sent, or at 24 hours; the rest stays in the pot0%The pot

What to watch

Things a launcher and a buyer should know before they use it.

  • lowOnly holders with at least 1/1,264 of all tokens × time held get paid in a round; smaller holders get nothing
  • lowThe keeper brings only holders of 1M+ tokens, though the code pays from 100,000
  • lowBuying more or receiving tokens lowers a wallet's recorded age, by weight
  • lowEvery transfer of the coin write-locks the hook's state, so trades of the coin run one at a time

Security review

Takes nothing from trades. Records when each holder's tokens arrived, and pays 90% of the pot in SOL to wallets by tokens held times time held.

Passed with notes
  • mediumThe script: keeper.json's least balance (1M tokens) differs from the code's MINHOLDING (100,000 tokens)

    keeper.json asks for holdings with minBalance 1000000000000, but round.rs MINHOLDING is 100000000000. The keeper will never bring holders between 100,000 and 1M tokens, though the code would pay them. Their share stays unpaid unless someone else sends the step. Align the two numbers.

  • lowThe script: shares below budget/1,264 are never paid, so small holders are excluded

    leastsend in round.rs skips any holder owed less than budget/1,264 (at least 0.0001 SOL). Totals also count off-curve and unreachable holders. So most small holders receive nothing, and a round that never reaches 90% sent runs the full 24 hours. Say so on the page, or lower the threshold.

  • lowThe script: the pot is read from a companion-owned account without deriving its address

    potin in lib.rs checks the owner, the mint at bytes 11..43 and the mode byte, but not that the account is PDA("companion", mint). It is one of the six accounts the companion passes, so the risk is small. Derive and compare the address anyway.

  • lowThe hook: the state is written on every transfer

    beforetransfer and beforeburn take the state account writable (the registry extra) to update calls and the totals. Every trade of the coin therefore write-locks one account, which limits how many trades fit in a block.

  • infoThe hook: program-owned holders and other pools are counted in the totals but never paid

    rules.rs records every owner but the launch and its pool, while the script pays only on-curve wallets. Vaults of other pools and PDAs dilute every round's total. This is safe for solvency, since shares never exceed the budget, but it lowers what wallets receive.

  • infoThe hook: anyone can make a holding's recorded age younger by sending it tokens

    blendarrivaltime treats tokens received as arriving now, whoever sends them. The effect is in proportion to the amount, so dust barely moves it, and the sender gives up the tokens. It can only lower a holder's share, never raise anyone's.

Checks

Approved 14 Oct, 06:03 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

    178,904 bytes, hash b651c2b5292f0b83b99ff665fd026682e68b5afede92a7524866f287a673c986.

  • 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 script: keeper.json's least balance (1M tokens) differs from the code's MINHOLDING (100,000 tokens).

  • 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

    166,600 bytes, hash 2fb23257539ece9dde799bffd0c0039659668dffce063f64bde2e580f63f0716.

What the trading test covered
  • Simulated a launch at unix time 1791936000 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

Builderv3Y2…rB8v
Hook program42vH…hvbn
Fee hook program8SeF…6wv2
Launch configNot made yet
Upgrade authorityCS1N…DW6W
Build hashb651c2b5…a673c986
Program size178,904 bytes
Hook runs
before transferbefore burnwrites hook data
Steps every15 s
Approved on chainYes
Submitted14 Oct, 05:59 UTC

Track record

No coin has launched with it yet.
In its builder’s words

Hook: Tenure's holder record. No cut, no refusal. Script: the launcher sets what share of the creator fee fills a pot. When the pot holds 0.15 SOL, at most once an hour, a round opens with 90% of the pot. Anyone may send steps. A step sends bridged SOL to holdings it brings, by coins times time held, 30 days at most. Passed over: holdings under 100,000 coins, owners that are programs or have no bridged-SOL holding, amounts under 0.0001 SOL or 1/1,264 of a round. Bordrless's keeper brings only holdings of 1,000,000 coins or more. Bordrless can change or stop both programs. Not an audit.

Source: the trade hook

The source Bordrless checked and built. Read it before you launch with it.

src/lib.rs284 lines ›
//! Tenure: a public record for every holder of a coin.
//!
//! On every transfer and burn the hook writes 64 bytes into each holding it touches: the average
//! time its coins arrived there, when the record began, when coins last left, the first and the
//! latest buy slot, and how many coins came from the pool, went to the pool and came from other
//! holdings. It keeps three totals per coin in its state: the number of holders, the coins they
//! hold and the sum that gives their average arrival time.
//!
//! Coins that arrive, arrive now, whoever sends them, so no transfer can make a holding older.
//! Any arrival first cuts the stored age to 30 days.
//!
//! The hook takes no cut and changes no amount. After the caller check no callback returns an
//! error, so it refuses no buy, sale, send or burn. It has no setting and no instruction besides
//! `prepare` and the six callbacks, and it treats every wallet alike.
//!
//! The launch's own holding and its pool's vault are never recorded. The pool is not read from
//! another program's account: the first transfer out of the launch's holding is the launch's
//! deposit into its pool, and the hook keeps the destination's owner from that transfer.
//!
//! `record` holds the 64-byte layout. `rules` holds the rules on plain values.

#![allow(unexpected_cfgs)]

use anchor_lang::prelude::*;
use bordrless_game::{LAUNCH_PROGRAM_ID, TOKEN_PROGRAM_ID};
use bordrless_hook::{
    hook_accounts_address, hook_signer, write_registry, AccountSource, ExtraAccount,
    HookAccountList, HookReturn, Seed, TokenHookArgs, TokenOp,
};

pub mod record;
pub mod rules;

use rules::{burn_rules, moment_of, transfer_rules, CoinBook, Side, Totals, LAUNCH_SEED};

declare_id!("58HoUKtUPpXTi3ZYXXPJeFq9oDK9k9ZoAvGxDKbmb5S6");

/// The flags a launch config has to name for this hook: `before_transfer` 1, `before_burn` 16
/// and hook data 128. A hook is not told its mint's flags, so the callbacks cannot check them.
pub const FLAGS: u16 = 145;

/// The callbacks' one extra account, after the token program's five: the state of the coin,
/// writable. Account 1 of every callback is the mint.
pub fn extra_accounts() -> HookAccountList {
    HookAccountList::new(vec![ExtraAccount {
        writable: true,
        source: AccountSource::Pda {
            program: crate::ID,
            seeds: vec![Seed::Literal(b"state".to_vec()), Seed::Account(1)],
        },
    }])
}

/// The state address of a coin.
pub fn state_address(mint: &Pubkey) -> Pubkey {
    Pubkey::find_program_address(&[b"state", mint.as_ref()], &crate::ID).0
}

#[program]
pub mod tenure {
    use super::*;

    /// The standard `prepare`: creates the state of `mint` and writes its registry. Anyone may
    /// send it, once per mint, and the result is the same bytes whoever sends it.
    pub fn prepare(ctx: Context<Prepare>) -> Result<()> {
        let mint = ctx.accounts.mint.key();
        let state = &mut ctx.accounts.state;
        state.mint = mint;
        state.launch =
            Pubkey::find_program_address(&[LAUNCH_SEED, mint.as_ref()], &LAUNCH_PROGRAM_ID).0;
        state.pool = Pubkey::default();
        state.version = 1;
        state.bump = ctx.bumps.state;
        state.calls = 0;
        state.holders = 0;
        state.held = 0;
        state.age_sum = 0;
        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(),
        )
    }

    /// Before every transfer: the source's record notes the outflow, the destination's record
    /// takes the coins in at the time of the call, and the totals follow. The launch's holding
    /// and the pool's vault are left alone.
    pub fn before_transfer(ctx: Context<Callback>, args: TokenHookArgs) -> Result<HookReturn> {
        check_call(&ctx, &args)?;
        let Ok(clock) = Clock::get() else {
            return Ok(HookReturn::default());
        };
        let state = &mut ctx.accounts.state;
        state.calls = state.calls.saturating_add(1);
        if args.op != TokenOp::Transfer {
            return Ok(HookReturn::default());
        }
        let outcome = transfer_rules(
            coin_book(state),
            Side {
                holding: args.source,
                owner: args.source_owner,
                balance: args.source_balance,
                stored_bytes: args.source_hook_data,
            },
            Side {
                holding: args.destination,
                owner: args.destination_owner,
                balance: args.destination_balance,
                stored_bytes: args.destination_hook_data,
            },
            args.amount,
            moment_of(clock.unix_timestamp, clock.slot),
        );
        if let Some(pool) = outcome.pool_from_deposit {
            state.pool = pool;
        }
        keep_totals(state, outcome.totals);
        let mut answer = HookReturn::default();
        answer.source_hook_data = outcome.source_bytes;
        answer.destination_hook_data = outcome.destination_bytes;
        Ok(answer)
    }

    /// After a transfer: nothing.
    pub fn after_transfer(ctx: Context<Callback>, args: TokenHookArgs) -> Result<()> {
        check_call(&ctx, &args)
    }

    /// Before a mint: nothing. The only mint of a launched coin goes to the launch's own holding.
    pub fn before_mint(ctx: Context<Callback>, args: TokenHookArgs) -> Result<HookReturn> {
        check_call(&ctx, &args)?;
        Ok(HookReturn::default())
    }

    /// After a mint: nothing.
    pub fn after_mint(ctx: Context<Callback>, args: TokenHookArgs) -> Result<()> {
        check_call(&ctx, &args)
    }

    /// Before every burn: the source's record notes the outflow and the totals follow. A holding
    /// that empties goes back to 64 zero bytes, so its owner can get its rent back.
    pub fn before_burn(ctx: Context<Callback>, args: TokenHookArgs) -> Result<HookReturn> {
        check_call(&ctx, &args)?;
        let Ok(clock) = Clock::get() else {
            return Ok(HookReturn::default());
        };
        let state = &mut ctx.accounts.state;
        state.calls = state.calls.saturating_add(1);
        if args.op != TokenOp::Burn {
            return Ok(HookReturn::default());
        }
        let outcome = burn_rules(
            coin_book(state),
            Side {
                holding: args.source,
                owner: args.source_owner,
                balance: args.source_balance,
                stored_bytes: args.source_hook_data,
            },
            args.amount,
            moment_of(clock.unix_timestamp, clock.slot),
        );
        keep_totals(state, outcome.totals);
        let mut answer = HookReturn::default();
        answer.source_hook_data = outcome.source_bytes;
        Ok(answer)
    }

    /// After a burn: nothing.
    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, about this state's mint.
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_ID, &crate::ID).0,
        HookError::BadHookSigner
    );
    require_keys_eq!(args.mint, ctx.accounts.state.mint, HookError::WrongAccount);
    Ok(())
}

/// The state's two unrecorded owners and its totals, as plain values for the rules.
fn coin_book(state: &TenureState) -> CoinBook {
    CoinBook {
        launch: state.launch,
        pool: state.pool,
        totals: Totals {
            holders: state.holders,
            held: state.held,
            age_sum: state.age_sum,
        },
    }
}

/// Writes the totals a call decided into the state.
fn keep_totals(state: &mut TenureState, totals: Totals) {
    state.holders = totals.holders;
    state.held = totals.held;
    state.age_sum = totals.age_sum;
}

/// The state of one coin, at `["state", mint]`. 146 bytes with Anchor's 8-byte discriminator.
#[account]
#[derive(InitSpace)]
pub struct TenureState {
    /// The coin this state was prepared for. `check_call` compares it.
    pub mint: Pubkey,
    /// The launchpad's `["launch", mint]`. Its holding is never recorded.
    pub launch: Pubkey,
    /// The launch's pool: the default key until the launch's deposit. Its vault is never recorded.
    pub pool: Pubkey,
    /// Layout version: 1.
    pub version: u8,
    /// Bump of this account.
    pub bump: u8,
    /// `before_transfer` and `before_burn` calls that passed `check_call`, saturating.
    pub calls: u64,
    /// Holdings that have a record. `rules::Totals` says on which mints the three totals are exact.
    pub holders: u64,
    /// The sum of their balances.
    pub held: u64,
    /// The sum of balance x `since` as stored, in base units x ticks: not cut to 30 days of age.
    pub age_sum: u128,
}

/// Accounts of the standard `prepare`.
#[derive(Accounts)]
pub struct Prepare<'info> {
    /// Pays the rent of the state and the registry.
    #[account(mut)]
    pub payer: Signer<'info>,
    /// CHECK: the coin the hook is prepared for. It need not exist yet.
    pub mint: UncheckedAccount<'info>,
    #[account(init, payer = payer, space = 8 + TenureState::INIT_SPACE, seeds = [b"state", mint.key().as_ref()], bump)]
    pub state: Box<Account<'info, TenureState>>,
    /// CHECK: the registry address. The handler compares it and writes it.
    #[account(mut)]
    pub registry: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}

/// Accounts of every callback: the token program's five, then the state.
#[derive(Accounts)]
pub struct Callback<'info> {
    /// CHECK: the token program's signer for this hook. `check_call` compares it.
    pub hook_signer: UncheckedAccount<'info>,
    /// CHECK: the coin.
    pub mint: UncheckedAccount<'info>,
    /// CHECK: the source holding. For a mint it is the mint.
    pub source: UncheckedAccount<'info>,
    /// CHECK: the destination holding. For a burn it is the mint.
    pub destination: UncheckedAccount<'info>,
    /// CHECK: who signed the operation.
    pub authority: UncheckedAccount<'info>,
    #[account(mut, seeds = [b"state", mint.key().as_ref()], bump = state.bump)]
    pub state: Box<Account<'info, TenureState>>,
}

/// The errors of `prepare` and `check_call`. A callback returns no other error.
#[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,
}
src/record.rs191 lines ›
//! The 64 bytes Tenure keeps in each holding, and how they are read and written.
//!
//! Little-endian. 60 bytes are used.
//!
//! | Offset | Size | Field            | Meaning                                                   |
//! | ------ | ---- | ---------------- | --------------------------------------------------------- |
//! | 0      | 3    | tag              | `54 4E 01`: "TN", layout 1                                |
//! | 3      | 1    | `flags`          | `BOUGHT`, `SOLD`, `SENT`, `RECEIVED`, `BURNED`            |
//! | 4      | 4    | `first_in`       | when this record began, unix seconds                      |
//! | 8      | 8    | `since`          | average arrival time of the coins held now, in ticks      |
//! | 16     | 8    | `first_buy_slot` | slot of the first arrival from the pool                   |
//! | 24     | 8    | `last_buy_slot`  | slot of the latest arrival from the pool                  |
//! | 32     | 8    | `bought`         | coins that arrived from the pool                          |
//! | 40     | 8    | `sold`           | coins sent to the pool                                    |
//! | 48     | 8    | `received`       | coins that arrived from other holdings                    |
//! | 56     | 4    | `last_out`       | when coins last left, unix seconds                        |
//! | 60     | 4    |                  | written 0, not read                                       |
//!
//! 65,536 ticks are 1 second. Bytes that do not start with the tag are no record: all zeros, or
//! another layout.

/// The first three bytes of a record: "TN", then layout 1.
pub const TAG: [u8; 3] = [0x54, 0x4E, 0x01];
/// Flag bit 0: coins arrived from the pool. That is a buy, whoever paid for it, or liquidity
/// taken out once the launch has graduated.
pub const BOUGHT: u8 = 1;
/// Flag bit 1: coins went to the pool. That is a sale, liquidity put in once the launch has
/// graduated, or a plain send to the pool's vault.
pub const SOLD: u8 = 2;
/// Flag bit 2: coins went to another holding. That may be a second wallet of the same person or
/// another pool, so a sale can stand behind it: a clear `SOLD` does not show that nothing was sold.
pub const SENT: u8 = 4;
/// Flag bit 3: coins arrived from another holding, asked for or not: anyone can send coins.
pub const RECEIVED: u8 = 8;
/// Flag bit 4: coins were burned.
pub const BURNED: u8 = 16;
/// The five flag bits in use: 1, 2, 4, 8 and 16 together. Bits 5 to 7 are written 0 and dropped
/// on read.
pub const KNOWN_FLAGS: u8 = 31;
/// What a holding with no record holds: 64 zero bytes.
pub const NO_RECORD: [u8; 64] = [0; 64];

/// The record of one holding, for the time since `first_in` only. A holding that empties goes
/// back to no record, and the next arrival begins a new one, so nothing from before `first_in`
/// is kept: no flag, no count, no age.
///
/// Who can change what. Coins can be delivered to any holding, by a send or by a buy that names
/// it, and its owner does not sign for that. So any wallet can set `BOUGHT` and `RECEIVED`,
/// raise `bought` and `received`, set `first_buy_slot` and `last_buy_slot` and move `since`
/// later. These fields describe coins that arrived, not acts of the owner. `SOLD`, `SENT`,
/// `BURNED`, `sold` and `last_out` change only when the owner or its delegate moves coins out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HolderRecord {
    /// `BOUGHT`, `SOLD`, `SENT`, `RECEIVED` and `BURNED`.
    pub flags: u8,
    /// When this record began: the first arrival since the holding was last empty, unix seconds.
    /// It is set by the first base unit to arrive, so it is not the age of the coins held now:
    /// `since` is.
    pub first_in: u32,
    /// Average arrival time of the coins held now, in ticks. It is stored as it is, not cut to
    /// 30 days: the cut is made when coins next arrive, even 1 base unit. So the age to read is
    /// the smaller of now less `since` and 30 days.
    pub since: u64,
    /// Slot of the first arrival from the pool. 0 while `BOUGHT` is clear.
    pub first_buy_slot: u64,
    /// Slot of the latest arrival from the pool. 0 while `BOUGHT` is clear.
    pub last_buy_slot: u64,
    /// Coins that arrived from the pool, summed, saturating: every such arrival since `first_in`,
    /// not the first one alone.
    pub bought: u64,
    /// Coins sent to the pool, summed, saturating.
    pub sold: u64,
    /// Coins that arrived from other holdings, summed, saturating.
    pub received: u64,
    /// When coins last left by a sale, a send or a burn, unix seconds. 0 while none left.
    pub last_out: u32,
}

/// A record that begins at `now`: no flags, no counts, every coin arriving at `now_ticks`.
pub fn fresh_record(now: u32, now_ticks: u64) -> HolderRecord {
    HolderRecord {
        flags: 0,
        first_in: now,
        since: now_ticks,
        first_buy_slot: 0,
        last_buy_slot: 0,
        bought: 0,
        sold: 0,
        received: 0,
        last_out: 0,
    }
}

/// Reads a record from a holding's 64 bytes. `None` when they do not start with the tag.
pub fn decode(stored_bytes: [u8; 64]) -> Option<HolderRecord> {
    if stored_bytes[0] != TAG[0] || stored_bytes[1] != TAG[1] || stored_bytes[2] != TAG[2] {
        return None;
    }
    Some(HolderRecord {
        flags: stored_bytes[3] & KNOWN_FLAGS,
        first_in: u32::from_le_bytes([
            stored_bytes[4],
            stored_bytes[5],
            stored_bytes[6],
            stored_bytes[7],
        ]),
        since: u64::from_le_bytes([
            stored_bytes[8],
            stored_bytes[9],
            stored_bytes[10],
            stored_bytes[11],
            stored_bytes[12],
            stored_bytes[13],
            stored_bytes[14],
            stored_bytes[15],
        ]),
        first_buy_slot: u64::from_le_bytes([
            stored_bytes[16],
            stored_bytes[17],
            stored_bytes[18],
            stored_bytes[19],
            stored_bytes[20],
            stored_bytes[21],
            stored_bytes[22],
            stored_bytes[23],
        ]),
        last_buy_slot: u64::from_le_bytes([
            stored_bytes[24],
            stored_bytes[25],
            stored_bytes[26],
            stored_bytes[27],
            stored_bytes[28],
            stored_bytes[29],
            stored_bytes[30],
            stored_bytes[31],
        ]),
        bought: u64::from_le_bytes([
            stored_bytes[32],
            stored_bytes[33],
            stored_bytes[34],
            stored_bytes[35],
            stored_bytes[36],
            stored_bytes[37],
            stored_bytes[38],
            stored_bytes[39],
        ]),
        sold: u64::from_le_bytes([
            stored_bytes[40],
            stored_bytes[41],
            stored_bytes[42],
            stored_bytes[43],
            stored_bytes[44],
            stored_bytes[45],
            stored_bytes[46],
            stored_bytes[47],
        ]),
        received: u64::from_le_bytes([
            stored_bytes[48],
            stored_bytes[49],
            stored_bytes[50],
            stored_bytes[51],
            stored_bytes[52],
            stored_bytes[53],
            stored_bytes[54],
            stored_bytes[55],
        ]),
        last_out: u32::from_le_bytes([
            stored_bytes[56],
            stored_bytes[57],
            stored_bytes[58],
            stored_bytes[59],
        ]),
    })
}

/// The 64 bytes of a record. Bytes 60 to 63 stay 0.
pub fn encode(record: HolderRecord) -> [u8; 64] {
    let mut new_bytes = [0u8; 64];
    new_bytes[0..3].copy_from_slice(&TAG);
    new_bytes[3] = record.flags & KNOWN_FLAGS;
    new_bytes[4..8].copy_from_slice(&record.first_in.to_le_bytes());
    new_bytes[8..16].copy_from_slice(&record.since.to_le_bytes());
    new_bytes[16..24].copy_from_slice(&record.first_buy_slot.to_le_bytes());
    new_bytes[24..32].copy_from_slice(&record.last_buy_slot.to_le_bytes());
    new_bytes[32..40].copy_from_slice(&record.bought.to_le_bytes());
    new_bytes[40..48].copy_from_slice(&record.sold.to_le_bytes());
    new_bytes[48..56].copy_from_slice(&record.received.to_le_bytes());
    new_bytes[56..60].copy_from_slice(&record.last_out.to_le_bytes());
    new_bytes
}
src/rules.rs376 lines ›
//! Tenure's rules on plain values: what a transfer or a burn does to the two records and to the
//! three totals of the coin. Nothing here reads an account or the clock.
//!
//! Every sum and product saturates, every division is checked, and every narrowing has a stated
//! fallback, so no input can stop a transfer.

use crate::record::{
    decode, encode, fresh_record, HolderRecord, BOUGHT, BURNED, NO_RECORD, RECEIVED, SENT, SOLD,
};
use anchor_lang::prelude::Pubkey;

/// Ticks in 1 second. Arrival times are kept in ticks, so the rounding of one arrival costs a
/// holding less than 1/65,536 s of age.
pub const TICKS_PER_SEC: u64 = 65_536;
/// The longest age a record keeps through an arrival: 30 days = 30 x 86,400 s.
pub const AGE_CAP_SECS: u32 = 2_592_000;
/// The same 30 days in ticks: 2,592,000 s x 65,536.
pub const AGE_CAP_TICKS: u64 = 169_869_312_000;
/// The latest second a record can hold: the largest u32, on 2106-02-07.
pub const LAST_SECOND: u32 = 4_294_967_295;

/// The time of one call, taken once from the chain clock.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Moment {
    /// Unix seconds, kept inside a u32.
    pub now: u32,
    /// `now` in ticks.
    pub now_ticks: u64,
    /// The slot of the call.
    pub slot: u64,
}

/// The three totals of a coin, or the part one holding adds to them.
///
/// The totals are exact for a coin launched on the launchpad whose flags have bits 1, 16 and 128
/// on: there the hook sees every coin a holder gets. On any other mint they can be wrong for
/// good. With other flags the hook misses calls or its answers are not stored. And the owner of
/// a mint made outside the launchpad can mint coins the hook never sees: on such a mint the pool
/// stays the default key.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Totals {
    /// Holdings that have a record.
    pub holders: u64,
    /// The sum of their balances.
    pub held: u64,
    /// The sum of balance x `since` as stored, in base units x ticks: not cut to 30 days of age.
    /// So `age_sum` over `held` is no average of ages cut to 30 days. That figure takes a scan of
    /// the holdings.
    pub age_sum: u128,
}

/// The part of a holding with no record: nothing.
pub const NO_TOTALS: Totals = Totals {
    holders: 0,
    held: 0,
    age_sum: 0,
};

/// One side of a call, as the token program reports it before the coins move.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Side {
    /// The holding account.
    pub holding: Pubkey,
    /// Who owns the holding.
    pub owner: Pubkey,
    /// Its balance before the call.
    pub balance: u64,
    /// Its 64 bytes before the call.
    pub stored_bytes: [u8; 64],
}

/// The launchpad's seed for a launch address, `["launch", mint]`: the six letters of "launch" in
/// ASCII, one byte each, written as numbers. `prepare` derives `CoinBook::launch` from it.
pub const LAUNCH_SEED: &[u8] = &[0x6C, 0x61, 0x75, 0x6E, 0x63, 0x68];

/// What the callbacks know about a coin: the two owners that are never recorded, and the totals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CoinBook {
    /// The launch address of the coin.
    pub launch: Pubkey,
    /// The launch's pool. The default key until the launch's deposit names it, once and for good:
    /// no later call changes it.
    pub pool: Pubkey,
    /// The totals before the call.
    pub totals: Totals,
}

/// What a call does to one holder: its new bytes, and its part of the totals before and after.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HolderChange {
    /// The bytes to store, or `None` to keep what is stored.
    pub new_bytes: Option<[u8; 64]>,
    /// The holding's part of the totals before the call.
    pub before: Totals,
    /// Its part after the call.
    pub after: Totals,
}

/// A side that is not a holder: nothing is written and nothing is counted.
pub const NO_CHANGE: HolderChange = HolderChange {
    new_bytes: None,
    before: NO_TOTALS,
    after: NO_TOTALS,
};

/// What one call decides.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Outcome {
    /// The pool, when this call is the launch's deposit that names it. `None` on any other call.
    pub pool_from_deposit: Option<Pubkey>,
    /// The totals after the call.
    pub totals: Totals,
    /// New bytes for the source holding, or `None` to keep what is stored.
    pub source_bytes: Option<[u8; 64]>,
    /// New bytes for the destination holding, or `None` to keep what is stored.
    pub destination_bytes: Option<[u8; 64]>,
}

/// The clock's seconds inside a u32: 0 for a time before 1970, the largest u32 past 2106.
pub fn clock_seconds(unix_timestamp: i64) -> u32 {
    u32::try_from(unix_timestamp.max(0)).unwrap_or(LAST_SECOND)
}

/// Seconds as ticks. At most 281,474,976,645,120, below 2^48.
pub fn seconds_to_ticks(seconds: u32) -> u64 {
    u64::from(seconds).saturating_mul(TICKS_PER_SEC)
}

/// The moment of a call from the clock's time and slot.
pub fn moment_of(unix_timestamp: i64, slot: u64) -> Moment {
    let now = clock_seconds(unix_timestamp);
    Moment {
        now,
        now_ticks: seconds_to_ticks(now),
        slot,
    }
}

/// What a holding with `balance` coins adds to the totals: one holder, its balance and
/// balance x `since`. Nothing when it has no record.
///
/// `since` is used as stored. The cut to 30 days is made in one place only, when coins arrive
/// (`blend_arrival_time`), so the sum of these parts is not a figure capped at 30 days.
pub fn part_of_totals(balance: u64, record: Option<HolderRecord>) -> Totals {
    match record {
        Some(known) => Totals {
            holders: 1,
            held: balance,
            age_sum: u128::from(balance).saturating_mul(u128::from(known.since)),
        },
        None => NO_TOTALS,
    }
}

/// The average arrival time, in ticks, of `held` coins that arrived at `held_since` once `added`
/// coins arrive at `now_ticks`.
///
/// The held coins first lose any age above 30 days, and a stored time in the future counts as
/// now. The average is rounded up to the next tick: a later time, a younger age. A balance is
/// below 2^64 and a time below 2^48, so each product is below 2^112 and their sum below 2^113.
pub fn blend_arrival_time(held: u64, held_since: u64, added: u64, now_ticks: u64) -> u64 {
    let floor_ticks = now_ticks.saturating_sub(AGE_CAP_TICKS);
    let held_since_capped = held_since.max(floor_ticks).min(now_ticks);
    let total = u128::from(held).saturating_add(u128::from(added));
    let sum = u128::from(held)
        .saturating_mul(u128::from(held_since_capped))
        .saturating_add(u128::from(added).saturating_mul(u128::from(now_ticks)));
    let rounded_up = sum.saturating_add(total.saturating_sub(1));
    let blended = rounded_up
        .checked_div(total)
        .unwrap_or(u128::from(now_ticks));
    u64::try_from(blended).unwrap_or(now_ticks).min(now_ticks)
}

/// Coins leave a holder by a sale (`SOLD`), a send (`SENT`) or a burn (`BURNED`).
///
/// A holding that empties goes back to 64 zero bytes, whatever it held, and leaves the totals.
/// Otherwise the record notes the outflow, and `since` and `first_in` stay exactly as stored:
/// no cut to 30 days is made here. Coins the hook never saw before count from `moment`.
pub fn coins_leave_holder(
    stored_bytes: [u8; 64],
    balance: u64,
    amount: u64,
    outflow_flag: u8,
    moment: Moment,
) -> HolderChange {
    let stored_record = decode(stored_bytes);
    let before = part_of_totals(balance, stored_record);
    let balance_left = balance.saturating_sub(amount);
    if balance_left == 0 {
        return HolderChange {
            new_bytes: Some(NO_RECORD),
            before,
            after: NO_TOTALS,
        };
    }
    let mut record = match stored_record {
        Some(known) => known,
        None => fresh_record(moment.now, moment.now_ticks),
    };
    record.last_out = moment.now;
    record.flags = record.flags | outflow_flag;
    if outflow_flag == SOLD {
        record.sold = record.sold.saturating_add(amount);
    }
    HolderChange {
        new_bytes: Some(encode(record)),
        before,
        after: part_of_totals(balance_left, Some(record)),
    }
}

/// Coins arrive at a holder, from the pool or from another holding. They arrive at `moment`,
/// whoever sends them: the sender's age does not come with them.
///
/// `first_in` and `last_out` stay as they are. Coins the hook never saw before count from
/// `moment`.
pub fn coins_arrive_at_holder(
    stored_bytes: [u8; 64],
    balance: u64,
    amount: u64,
    from_pool: bool,
    moment: Moment,
) -> HolderChange {
    let stored_record = decode(stored_bytes);
    let before = part_of_totals(balance, stored_record);
    let mut record = match stored_record {
        Some(known) => known,
        None => fresh_record(moment.now, moment.now_ticks),
    };
    record.since = blend_arrival_time(balance, record.since, amount, moment.now_ticks);
    if from_pool {
        if record.flags & BOUGHT == 0 {
            record.first_buy_slot = moment.slot;
        }
        record.last_buy_slot = moment.slot;
        record.flags = record.flags | BOUGHT;
        record.bought = record.bought.saturating_add(amount);
    } else {
        record.flags = record.flags | RECEIVED;
        record.received = record.received.saturating_add(amount);
    }
    HolderChange {
        new_bytes: Some(encode(record)),
        before,
        after: part_of_totals(balance.saturating_add(amount), Some(record)),
    }
}

/// The totals after a call: both sides' old parts are taken out first, then both new parts are
/// put in.
pub fn settle_totals(
    totals: Totals,
    source_change: HolderChange,
    destination_change: HolderChange,
) -> Totals {
    Totals {
        holders: totals
            .holders
            .saturating_sub(source_change.before.holders)
            .saturating_sub(destination_change.before.holders)
            .saturating_add(source_change.after.holders)
            .saturating_add(destination_change.after.holders),
        held: totals
            .held
            .saturating_sub(source_change.before.held)
            .saturating_sub(destination_change.before.held)
            .saturating_add(source_change.after.held)
            .saturating_add(destination_change.after.held),
        age_sum: totals
            .age_sum
            .saturating_sub(source_change.before.age_sum)
            .saturating_sub(destination_change.before.age_sum)
            .saturating_add(source_change.after.age_sum)
            .saturating_add(destination_change.after.age_sum),
    }
}

/// A transfer of `amount` coins from `source` to `destination`.
///
/// The launch's holding and the pool's vault are never recorded. The first transfer out of the
/// launch's holding is its deposit, and the destination's owner is the pool from then on.
///
/// The rules see owners and amounts only, never SOL. A transfer from the pool counts as a buy
/// and one to the pool as a sale, whatever moved the coins: a trade, liquidity taken out or put
/// in once the launch has graduated, or a plain send to the vault. Any other transfer is a send,
/// a trade on another pool included.
pub fn transfer_rules(
    book: CoinBook,
    source: Side,
    destination: Side,
    amount: u64,
    moment: Moment,
) -> Outcome {
    let nothing_changes = Outcome {
        pool_from_deposit: None,
        totals: book.totals,
        source_bytes: None,
        destination_bytes: None,
    };
    if amount == 0
        || amount > source.balance
        || source.holding == destination.holding
        || source.owner == destination.owner
    {
        return nothing_changes;
    }
    let pool_is_known = book.pool != Pubkey::default();
    if source.owner == book.launch {
        // Only the launchpad signs for the launch's holding, and it moves the coin out of it in
        // three places: `create_launch` deposits into the vault of the pool it has just made,
        // and `graduate` tops that vault up and burns what is left. The deposit comes first, in
        // the instruction that creates the mint, so the first destination owner is the pool.
        if pool_is_known {
            return nothing_changes;
        }
        return Outcome {
            pool_from_deposit: Some(destination.owner),
            ..nothing_changes
        };
    }
    let from_pool = pool_is_known && source.owner == book.pool;
    let to_pool = pool_is_known && destination.owner == book.pool;
    let to_launch = destination.owner == book.launch;
    let source_change = if from_pool {
        NO_CHANGE
    } else if to_pool {
        coins_leave_holder(source.stored_bytes, source.balance, amount, SOLD, moment)
    } else {
        coins_leave_holder(source.stored_bytes, source.balance, amount, SENT, moment)
    };
    let destination_change = if to_pool || to_launch {
        NO_CHANGE
    } else {
        coins_arrive_at_holder(
            destination.stored_bytes,
            destination.balance,
            amount,
            from_pool,
            moment,
        )
    };
    Outcome {
        pool_from_deposit: None,
        totals: settle_totals(book.totals, source_change, destination_change),
        source_bytes: source_change.new_bytes,
        destination_bytes: destination_change.new_bytes,
    }
}

/// A burn of `amount` coins from `source`. A burn from the launch's holding or from the pool's
/// vault changes nothing.
pub fn burn_rules(book: CoinBook, source: Side, amount: u64, moment: Moment) -> Outcome {
    let nothing_changes = Outcome {
        pool_from_deposit: None,
        totals: book.totals,
        source_bytes: None,
        destination_bytes: None,
    };
    if amount == 0 || amount > source.balance {
        return nothing_changes;
    }
    let pool_is_known = book.pool != Pubkey::default();
    if source.owner == book.launch || (pool_is_known && source.owner == book.pool) {
        return nothing_changes;
    }
    let source_change =
        coins_leave_holder(source.stored_bytes, source.balance, amount, BURNED, moment);
    Outcome {
        pool_from_deposit: None,
        totals: settle_totals(book.totals, source_change, NO_CHANGE),
        source_bytes: source_change.new_bytes,
        destination_bytes: None,
    }
}

Source: the fee hook

The source Bordrless checked and built. Read it before you launch with it.

src/lib.rs542 lines ›
//! Tenure Pot: the script half of a pair. The hook half is Tenure, which writes into each holding
//! when its coins arrived. This script shares the coin's fee pot among the holdings a step
//! brings, by coins held times time held as Tenure recorded it, 30 days at most.
//!
//! Bordrless's companion program holds the pot as bridged SOL, in the holding of its creator
//! address for the coin, and calls this script on a timer with that address signing. Anyone may
//! send a step and chooses the accounts it brings. So every account after the first six is
//! trusted for nothing: it is found by its address or read and tested.
//!
//! - `companion_init` runs once, before the coin exists. It takes no settings: its init data
//!   has to be empty and the state has to be 10,240 bytes. It writes the layout version and the
//!   coin into the state.
//! - `companion_step` opens a round when the last one is over and the pot holds 0.15 SOL. The
//!   round fixes its time, its budget (90% of the pot) and the coin-seconds of all holders, read
//!   from Tenure's totals. Then the step takes each holding it was given: a holding of this
//!   coin, owned by a wallet, of 100,000 coins or more, with a Tenure record, not sent to in
//!   this round. It works out the holding's part of the budget. A part of the round's least
//!   amount or more is sent as bridged SOL to the bridged-SOL holding of the holding's owner,
//!   at its derived address and nowhere else, and then the holding's key joins the list in the
//!   state, so it is sent to once a round. A holding with a smaller part is passed over:
//!   nothing is sent and no entry is written. The least amount is the larger of 0.0001 SOL and
//!   the round's budget divided by the 1,264 entries of the list, rounded up, so the list is
//!   full only when the budget is spent. Coins that arrived after the round opened count no
//!   time in it.
//!
//! A round takes holdings for its first hour, whatever it has sent. It is over when it is an hour
//! old and 90% of its budget was sent, or when it is 24 hours old. No instruction closes it. What
//! a round did not send stays in the pot for the next one.
//!
//! A step that sends to no holding fails, also when it would have opened a round. A failed step
//! changes nothing: no round opened and no entry was written. The only call to another program
//! is the token program's `transfer` of bridged SOL. The script has no setting, no admin and no
//! wallet address in its code. A step sets no return data.
//!
//! The accounts a step takes after the companion's six, in any order, each found by its address:
//!
//! - Tenure's state of the coin, `["state", mint]` under the mint's hook program. Only a step
//!   that opens a round reads it.
//! - Each holding to send to, and the bridged-SOL holding of its owner, writable.
//! - The creator address's coin holding, its bridged-SOL holding (writable), the bridged-SOL
//!   mint, the token program and its event authority. The companion's own step carries all five.
//!
//! Every one of them derives from the mint or from a holding's owner, so anyone can send a step.
//!
//! `layout` holds the bytes read from other programs' accounts and the list. `round` holds the
//! arithmetic.

#![allow(unexpected_cfgs)]

use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::invoke;
use bordrless_token::client as token_client;
use bordrless_token::state::Holding;

pub mod layout;
pub mod round;

use layout::{
    list_key, pot_of, record_since, tenure_totals, HEADER_LEN, STATE_LEN, TENURE_FLAG_BITS,
};
use round::{
    clock_seconds, may_open, note_sent, open_round, pot_holds, record, round_over, round_total,
    share_for, step_has_room, Holder, Outcome, Round,
};

declare_id!("5daSf4tMosLtcmvtsNi1pee6VbyQUXXWeZonjhQ39swU");

/// Bordrless's companion program. Its creator address for a coin is the one signer of every
/// call, and it makes this script's state.
pub const COMPANION_ID: Pubkey =
    Pubkey::from_str_const("6ZUM1gWBH9hBBNoJoaVAGwSftyZ6CUda6vUZTW9MsJuo");
/// Bridged SOL, the token the pot is held in.
pub const BRIDGED_SOL_MINT: Pubkey =
    Pubkey::from_str_const("A49oVhX22ExMwTEtFC6Y8nhBdZ4LJDGhdXLDn4c2f59i");
/// `["creator", mint]` under the companion program: the creator address of a coin.
pub const CREATOR_SEED: &[u8] = b"creator";
/// `["script-state", mint]` under the companion program: this script's state for a coin.
pub const SCRIPT_STATE_SEED: &[u8] = b"script-state";
/// `["state", mint]` under the coin's hook program: Tenure's state for a coin.
pub const TENURE_STATE_SEED: &[u8] = b"state";
/// Layout version of [`PotState`].
pub const VERSION: u8 = 1;

#[program]
pub mod tenure_pot {
    use super::*;

    /// Called once by the companion when a launch is set up, the creator address signing. The
    /// accounts are checked by [`CompanionInit`]. There are no settings, so any init data fails.
    pub fn companion_init(
        ctx: Context<CompanionInit>,
        mint: Pubkey,
        init_data: Vec<u8>,
    ) -> Result<()> {
        require!(init_data.is_empty(), PotError::InitDataNotEmpty);
        let state = &mut ctx.accounts.state;
        state.version = VERSION;
        state.zero_a = [0; 3];
        state.handled = 0;
        state.mint = mint;
        state.round = 0;
        state.opened_at = 0;
        state.budget = 0;
        state.sent = 0;
        state.total = 0;
        state.sent_total = 0;
        state.transfers = 0;
        state.zero_b = [0; 20];
        Ok(())
    }

    /// Called by the companion at most once an interval, the creator address signing. Who calls
    /// and which state this is are checked by [`CompanionStep`].
    pub fn companion_step<'info>(
        ctx: Context<'info, CompanionStep<'info>>,
        mint: Pubkey,
    ) -> Result<()> {
        let now = clock_seconds(Clock::get()?.unix_timestamp);
        let pot = pot_in(&ctx.accounts.companion, &mint)?;
        let extras = ctx.remaining_accounts;
        let creator = ctx.accounts.creator.to_account_info();
        let creator_address = ctx.accounts.creator.key();
        let mut round = ctx.accounts.state.numbers();

        // A round opens when the last one is over, the pot holds the floor and the hour passed.
        // A round that is not over stays as it is and takes the holdings below. The opening is
        // kept only if this step also sends to a holding.
        if round_over(&round, now) {
            require!(may_open(&round, now, pot), PotError::NothingToDo);
            let (held, age_sum) = totals_in(&ctx.accounts.mint_account, extras, &mint)?;
            let (creator_amount, creator_since) = creator_part_in(extras, &mint, &creator_address)?;
            let total = round_total(held, age_sum, now, creator_amount, creator_since);
            require!(total > 0, PotError::NothingToDo);
            open_round(&mut round, now, pot, total);
            emit!(RoundOpened {
                mint,
                round: round.round,
                opened_at: i64::try_from(round.opened_at).unwrap_or(i64::MAX),
                budget: round.budget,
                total: round.total,
            });
        }

        // The holdings the step brought, in the order given. A holding is either sent to, and
        // then written into the list, or passed over with nothing changed.
        let mut transfers: u32 = 0;
        let mut spent: u64 = 0;
        let mut outbox: Option<Outbox> = None;
        {
            let state_account = ctx.accounts.state.to_account_info();
            let mut state_bytes = state_account.try_borrow_mut_data()?;
            let Some(list) = state_bytes.get_mut(HEADER_LEN..) else {
                return err!(PotError::BadState);
            };
            for account in extras {
                if !step_has_room(transfers, round.handled) {
                    break;
                }
                // Owned by the token program, and it decodes as a holding.
                let Some(holding) = holding_in(account) else {
                    continue;
                };
                let key = list_key(&account.key.to_bytes());
                let holder = Holder {
                    mint_matches: holding.mint == mint,
                    owner_default: holding.owner == Pubkey::default(),
                    owner_is_creator: holding.owner == creator_address,
                    owner_on_curve: holding.owner.is_on_curve(),
                    amount: holding.amount,
                    since: record_since(&holding.hook_data),
                    key,
                };
                let Outcome::Send(lamports) = share_for(&holder, &round, list) else {
                    continue;
                };
                // Only ever the bridged-SOL holding derived from this holding's owner.
                let Some(destination) = destination_in(extras, &holding.owner) else {
                    continue;
                };
                if !pot_holds(lamports, pot, spent) {
                    continue;
                }
                let fixed = match outbox {
                    Some(found) => found,
                    None => {
                        let found = outbox_in(extras, &creator_address)?;
                        outbox = Some(found);
                        found
                    }
                };
                send(&creator, &fixed, destination, lamports)?;
                note_sent(&mut round, lamports);
                spent = spent.saturating_add(lamports);
                transfers = transfers.saturating_add(1);
                // The entry, right after the send. A full list ends the loop before this point,
                // so every holding sent to is written.
                require!(record(&mut round, list, key), PotError::BadState);
            }
        }

        // A step that sent to no holding fails, also when it opened a round above. Nothing it
        // did is kept: the round is not open, and the companion moves no bounty and no timer.
        require!(transfers > 0, PotError::NothingToDo);
        ctx.accounts.state.keep(&round);
        emit!(StepDone {
            mint,
            round: round.round,
            recorded: transfers,
            transfers,
            lamports: spent,
        });
        Ok(())
    }
}

/// The pot of the coin: read from the companion's own account, the number the companion holds
/// the step to when it returns.
fn pot_in(companion: &AccountInfo, mint: &Pubkey) -> Result<u64> {
    if companion.owner != &COMPANION_ID {
        return err!(PotError::BadCompanion);
    }
    let Ok(bytes) = companion.try_borrow_data() else {
        return err!(PotError::BadCompanion);
    };
    match pot_of(&bytes, &mint.to_bytes()) {
        Some(pot) => Ok(pot),
        None => err!(PotError::BadCompanion),
    }
}

/// An account among the extras, by its address.
fn find_in<'a, 'info>(
    extras: &'a [AccountInfo<'info>],
    address: &Pubkey,
) -> Option<&'a AccountInfo<'info>> {
    extras.iter().find(|account| account.key == address)
}

/// A holding of the token program, read from its account. `None` when the account is not the
/// token program's or does not decode.
fn holding_in(account: &AccountInfo) -> Option<Holding> {
    if account.owner != &bordrless_token::ID {
        return None;
    }
    token_client::read_holding(account).ok()
}

/// Tenure's `held` and `age_sum` for the coin. They are read only from the state at
/// `["state", mint]` under the hook program the mint itself names, owned by that program.
fn totals_in<'info>(
    mint_account: &AccountInfo<'info>,
    extras: &[AccountInfo<'info>],
    mint: &Pubkey,
) -> Result<(u64, u128)> {
    let Ok(coin) = token_client::read_mint(mint_account) else {
        return err!(PotError::NoHook);
    };
    let Some(hook) = coin.hook_program else {
        return err!(PotError::NoHook);
    };
    if (coin.hook_flags & TENURE_FLAG_BITS) != TENURE_FLAG_BITS {
        return err!(PotError::NoHook);
    }
    let (address, _bump) =
        Pubkey::find_program_address(&[TENURE_STATE_SEED, mint.as_ref()], &hook);
    let Some(account) = find_in(extras, &address) else {
        return err!(PotError::NoRecordState);
    };
    if account.owner != &hook {
        return err!(PotError::NoRecordState);
    }
    let Ok(bytes) = account.try_borrow_data() else {
        return err!(PotError::NoRecordState);
    };
    match tenure_totals(&bytes, &mint.to_bytes()) {
        Some(totals) => Ok(totals),
        None => err!(PotError::NoRecordState),
    }
}

/// The dev's coins in the creator address's own holding, which Tenure counts and a round leaves
/// out: its balance and its `since`. The holding is taken at its derived address. Both are 0
/// when it has no record or no coins.
fn creator_part_in(
    extras: &[AccountInfo],
    mint: &Pubkey,
    creator_address: &Pubkey,
) -> Result<(u64, u64)> {
    let address = token_client::holding_address(mint, creator_address);
    let Some(account) = find_in(extras, &address) else {
        return err!(PotError::MissingAccount);
    };
    let Some(holding) = holding_in(account) else {
        return Ok((0, 0));
    };
    if &holding.mint != mint || &holding.owner != creator_address || holding.amount == 0 {
        return Ok((0, 0));
    }
    match record_since(&holding.hook_data) {
        Some(since) => Ok((holding.amount, since)),
        None => Ok((0, 0)),
    }
}

/// Where a holding's amount goes: the bridged-SOL holding at the address derived from the
/// holding's owner. `None` when the step did not bring it, it is not the token program's, or it
/// was passed read-only.
fn destination_in<'a, 'info>(
    extras: &'a [AccountInfo<'info>],
    owner: &Pubkey,
) -> Option<&'a AccountInfo<'info>> {
    let address = token_client::holding_address(&BRIDGED_SOL_MINT, owner);
    let account = find_in(extras, &address)?;
    if account.owner != &bordrless_token::ID || !account.is_writable {
        return None;
    }
    Some(account)
}

/// The accounts every transfer of bridged SOL takes besides its destination.
#[derive(Clone, Copy)]
struct Outbox<'a, 'info> {
    /// The creator address's bridged-SOL holding, at its derived address: where the pot is.
    source: &'a AccountInfo<'info>,
    bridged_sol_mint: &'a AccountInfo<'info>,
    token_program: &'a AccountInfo<'info>,
    token_event_authority: &'a AccountInfo<'info>,
}

fn outbox_in<'a, 'info>(
    extras: &'a [AccountInfo<'info>],
    creator_address: &Pubkey,
) -> Result<Outbox<'a, 'info>> {
    let source_address = token_client::holding_address(&BRIDGED_SOL_MINT, creator_address);
    let found = (
        find_in(extras, &source_address),
        find_in(extras, &BRIDGED_SOL_MINT),
        find_in(extras, &bordrless_token::ID),
        find_in(extras, &token_client::event_authority()),
    );
    let (Some(source), Some(bridged_sol_mint), Some(token_program), Some(token_event_authority)) =
        found
    else {
        return err!(PotError::MissingAccount);
    };
    if !source.is_writable {
        return err!(PotError::MissingAccount);
    }
    Ok(Outbox {
        source,
        bridged_sol_mint,
        token_program,
        token_event_authority,
    })
}

/// One `transfer` of bridged SOL from the pot to `destination`, the creator address signing.
/// Bridged SOL has no hook, so the token program's id stands in both hook places.
fn send<'info>(
    creator: &AccountInfo<'info>,
    fixed: &Outbox<'_, 'info>,
    destination: &AccountInfo<'info>,
    lamports: u64,
) -> Result<()> {
    let transfer = token_client::transfer_with(
        creator.key(),
        fixed.source.key(),
        destination.key(),
        BRIDGED_SOL_MINT,
        None,
        Vec::new(),
        lamports,
    );
    invoke(
        &transfer,
        &[
            creator.clone(),
            fixed.source.clone(),
            destination.clone(),
            fixed.bridged_sol_mint.clone(),
            fixed.token_program.clone(),
            fixed.token_event_authority.clone(),
        ],
    )?;
    Ok(())
}

/// The state of one coin, at `["script-state", mint]` under the companion program: this
/// 128-byte header, then a list of 1,264 entries of 8 bytes, 10,240 bytes in all. The list has
/// one entry per holding sent to in the round: the first 8 bytes of the holding's address.
/// Entries from `handled` on are left over from earlier rounds and are not read.
#[account]
#[derive(InitSpace)]
pub struct PotState {
    /// Layout version: 1.
    pub version: u8,
    /// Written 0.
    pub zero_a: [u8; 3],
    /// Entries of the list in use in this round, 0 to 1,264. Each is a holding that was sent to.
    pub handled: u32,
    /// The coin.
    pub mint: Pubkey,
    /// Number of the round, 0 before the first.
    pub round: u64,
    /// When the round opened, unix seconds.
    pub opened_at: i64,
    /// Lamports the round may send in all.
    pub budget: u64,
    /// Lamports the round has sent.
    pub sent: u64,
    /// Coin-seconds of all holders at `opened_at`: base units x seconds.
    pub total: u128,
    /// Lamports sent in all rounds, saturating.
    pub sent_total: u64,
    /// Transfers of bridged SOL made in this round. Equal to `handled`: an entry is written
    /// only for a holding that was sent to. The field stays, so the layout does not change.
    pub transfers: u32,
    /// Written 0.
    pub zero_b: [u8; 20],
}

impl PotState {
    /// The header's numbers as the rules take them.
    pub fn numbers(&self) -> Round {
        Round {
            round: self.round,
            opened_at: clock_seconds(self.opened_at),
            budget: self.budget,
            sent: self.sent,
            total: self.total,
            handled: self.handled,
            transfers: self.transfers,
            sent_total: self.sent_total,
        }
    }

    /// Writes the numbers a step decided into the header.
    pub fn keep(&mut self, round: &Round) {
        self.round = round.round;
        self.opened_at = i64::try_from(round.opened_at).unwrap_or(i64::MAX);
        self.budget = round.budget;
        self.sent = round.sent;
        self.total = round.total;
        self.handled = round.handled;
        self.transfers = round.transfers;
        self.sent_total = round.sent_total;
    }
}

/// Accounts of `companion_init`, as the companion passes them.
#[derive(Accounts)]
#[instruction(mint: Pubkey)]
pub struct CompanionInit<'info> {
    /// The companion's creator address for the coin. Only the companion can sign for it.
    #[account(seeds = [CREATOR_SEED, mint.as_ref()], bump, seeds::program = COMPANION_ID)]
    pub creator: Signer<'info>,
    /// CHECK: the coin, not created yet. Only its address is compared.
    #[account(address = mint @ PotError::WrongMint)]
    pub mint_account: UncheckedAccount<'info>,
    /// The state the companion made for the coin: at its derived address, this program's, all
    /// zeros, and of the one size this script takes.
    #[account(
        zero,
        seeds = [SCRIPT_STATE_SEED, mint.as_ref()],
        bump,
        seeds::program = COMPANION_ID,
        constraint = state.to_account_info().data_len() == STATE_LEN @ PotError::BadState,
    )]
    pub state: Account<'info, PotState>,
    pub system_program: Program<'info, System>,
}

/// Accounts of `companion_step`, as the companion passes them. The accounts after these six are
/// chosen by whoever sends the step.
#[derive(Accounts)]
#[instruction(mint: Pubkey)]
pub struct CompanionStep<'info> {
    /// The companion's creator address for the coin. Only the companion can sign for it.
    #[account(seeds = [CREATOR_SEED, mint.as_ref()], bump, seeds::program = COMPANION_ID)]
    pub creator: Signer<'info>,
    /// CHECK: the coin. Its address is compared here, and it is read when a round opens.
    #[account(address = mint @ PotError::WrongMint)]
    pub mint_account: UncheckedAccount<'info>,
    /// CHECK: the launch. Not read.
    pub launch: UncheckedAccount<'info>,
    /// CHECK: the companion's account of the coin. The handler checks its owner, its coin and
    /// its mode before it reads the pot.
    pub companion: UncheckedAccount<'info>,
    /// CHECK: the launch's pool. Not read.
    pub pool: UncheckedAccount<'info>,
    #[account(
        mut,
        constraint = state.to_account_info().data_len() == STATE_LEN @ PotError::BadState,
        constraint = state.version == VERSION @ PotError::NotInitialized,
        constraint = state.mint == mint @ PotError::WrongMint,
    )]
    pub state: Account<'info, PotState>,
}

/// A round opened: the three numbers it fixed.
#[event]
pub struct RoundOpened {
    pub mint: Pubkey,
    pub round: u64,
    pub opened_at: i64,
    pub budget: u64,
    pub total: u128,
}

/// A step ended: the holdings it sent to and the lamports it sent. `recorded` and `transfers`
/// are the same number, since an entry is written only for a holding that was sent to.
#[event]
pub struct StepDone {
    pub mint: Pubkey,
    pub round: u64,
    pub recorded: u32,
    pub transfers: u32,
    pub lamports: u64,
}

#[error_code]
pub enum PotError {
    #[msg("this script takes no settings: the init data has to be empty")]
    InitDataNotEmpty,
    #[msg("the state is not 10,240 bytes long")]
    BadState,
    #[msg("the state was never initialized")]
    NotInitialized,
    #[msg("an account is of another mint")]
    WrongMint,
    #[msg("the companion account is not this coin's, or not a script companion's")]
    BadCompanion,
    #[msg("the mint has no hook with Tenure's flags")]
    NoHook,
    #[msg("the hook's state for this mint was not passed, or is not a Tenure state")]
    NoRecordState,
    #[msg("an account the step needs was not passed")]
    MissingAccount,
    #[msg("no round can open, or the step sent to no holding")]
    NothingToDo,
}
keeper.json17 lines ›
{
  "version": 1,
  "holdings": {
    "minBalance": "1000000000000",
    "maxBalance": null,
    "hookData": { "offset": 0, "width": 2, "compare": "eq", "against": { "const": "20052" } },
    "payTo": "ownerQuoteHolding",
    "perStep": { "withSwapAccounts": null, "withoutSwapAccounts": 6 }
  },
  "swapAccounts": false,
  "hookAccounts": true,
  "payouts": false,
  "roundSecs": 3600,
  "stepSecs": 15,
  "stateLen": 10240
}
src/layout.rs183 lines ›
//! The bytes this script reads from accounts of other programs, and the list in its own state.
//!
//! Every function here takes plain bytes and answers `None` when they are not what it expects.
//! None reads an account, and none fails: the caller decides what a `None` means.
//!
//! What the script relies on in the hook half of the pair, Tenure. From the 64 bytes Tenure keeps
//! in each holding:
//!
//! | Bytes   | What                                                              |
//! | ------- | ----------------------------------------------------------------- |
//! | 0 to 2  | the tag `54 4E 01`: this holding has a Tenure record              |
//! | 8 to 15 | `since`, u64: when the coins arrived, averaged by balance, ticks  |
//!
//! From Tenure's state of a coin, at `["state", mint]` under the hook program, 146 bytes:
//!
//! | Offset | Size | What                                                          |
//! | ------ | ---- | ------------------------------------------------------------- |
//! | 0      | 8    | `d7 9c 02 56 c8 78 9a bf`: the account is a `TenureState`     |
//! | 8      | 32   | `mint`: the coin                                              |
//! | 72     | 32   | `pool`: all zero until the launch has made its deposit        |
//! | 104    | 1    | `version`: 1                                                  |
//! | 122    | 8    | `held`, u64: the balances of all recorded holdings, summed    |
//! | 130    | 16   | `age_sum`, u128: balance x `since` over the same holdings     |
//!
//! From the companion's account of a coin, at `["companion", mint]` under the companion program,
//! 300 bytes or more:
//!
//! | Offset | Size | What                                                          |
//! | ------ | ---- | ------------------------------------------------------------- |
//! | 11     | 32   | `mint`: the coin                                              |
//! | 270    | 8    | `pending_pot`, u64: the pot, in lamports of bridged SOL       |
//! | 299    | 1    | `mode`: 2 for a coin whose pot a script spends                |
//!
//! All numbers are little-endian. 65,536 ticks are 1 second.

/// The size of this script's state: the most the companion makes.
pub const STATE_LEN: usize = 10_240;
/// The header of the state. The list follows it.
pub const HEADER_LEN: usize = 128;
/// One entry of the list: the first 8 bytes of a holding's address.
pub const ENTRY_LEN: usize = 8;
/// Entries the list holds: 10,240 less 128, in entries of 8 bytes.
pub const LIST_CAP: u32 = 1_264;

/// The first three bytes of a Tenure record: "TN", then layout 1.
pub const TENURE_TAG: [u8; 3] = [0x54, 0x4E, 0x01];
/// The first eight bytes of Tenure's state: the account is a `TenureState`.
pub const TENURE_STATE_TAG: [u8; 8] = [0xd7, 0x9c, 0x02, 0x56, 0xc8, 0x78, 0x9a, 0xbf];
/// The flags a coin on Tenure has on: `before_transfer` 1, `before_burn` 16, hook data 128.
pub const TENURE_FLAG_BITS: u16 = 145;
/// The least length of Tenure's state.
pub const TENURE_STATE_LEN: usize = 146;
/// Where Tenure's state keeps the coin. It ends at byte 40: 8 and 32.
pub const TENURE_MINT_AT: usize = 8;
pub const TENURE_MINT_END: usize = 40;
/// Where Tenure's state keeps the pool. It ends at byte 104: 72 and 32.
pub const TENURE_POOL_AT: usize = 72;
pub const TENURE_POOL_END: usize = 104;
/// Where Tenure's state keeps its layout version, and the one this script reads.
pub const TENURE_VERSION_AT: usize = 104;
pub const TENURE_VERSION: u8 = 1;
/// Where Tenure's state keeps `held` and `age_sum`.
pub const TENURE_HELD_AT: usize = 122;
pub const TENURE_AGE_SUM_AT: usize = 130;

/// The least length of the companion's account.
pub const COMPANION_LEN: usize = 300;
/// Where the companion's account keeps the coin. It ends at byte 43: 11 and 32.
pub const COMPANION_MINT_AT: usize = 11;
pub const COMPANION_MINT_END: usize = 43;
/// Where the companion's account keeps the pot.
pub const COMPANION_POT_AT: usize = 270;
/// Where the companion's account keeps its mode, and the mode of a coin with a script.
pub const COMPANION_MODE_AT: usize = 299;
pub const MODE_SCRIPT: u8 = 2;

/// The u64 at `offset` of `bytes`.
fn u64_at(bytes: &[u8], offset: usize) -> Option<u64> {
    let end = offset.checked_add(8)?;
    let field = bytes.get(offset..end)?;
    Some(u64::from_le_bytes(field.try_into().ok()?))
}

/// The u128 at `offset` of `bytes`.
fn u128_at(bytes: &[u8], offset: usize) -> Option<u128> {
    let end = offset.checked_add(16)?;
    let field = bytes.get(offset..end)?;
    Some(u128::from_le_bytes(field.try_into().ok()?))
}

/// The pot of `mint`, read from the bytes of the companion's account. `None` when the account is
/// too short, is of another coin, or is not in the mode of a coin with a script.
pub fn pot_of(companion: &[u8], mint: &[u8; 32]) -> Option<u64> {
    if companion.len() < COMPANION_LEN {
        return None;
    }
    if companion.get(COMPANION_MINT_AT..COMPANION_MINT_END)? != mint.as_slice() {
        return None;
    }
    if companion.get(COMPANION_MODE_AT)? != &MODE_SCRIPT {
        return None;
    }
    u64_at(companion, COMPANION_POT_AT)
}

/// Tenure's `held` and `age_sum` for `mint`, read from the bytes of its state. `None` when the
/// bytes are too short, do not start with the tag, are of another coin or another layout, or name
/// no pool yet.
pub fn tenure_totals(state: &[u8], mint: &[u8; 32]) -> Option<(u64, u128)> {
    if state.len() < TENURE_STATE_LEN {
        return None;
    }
    if state.get(..TENURE_STATE_TAG.len())? != TENURE_STATE_TAG.as_slice() {
        return None;
    }
    if state.get(TENURE_MINT_AT..TENURE_MINT_END)? != mint.as_slice() {
        return None;
    }
    if state.get(TENURE_VERSION_AT)? != &TENURE_VERSION {
        return None;
    }
    let pool = state.get(TENURE_POOL_AT..TENURE_POOL_END)?;
    if pool.iter().all(|byte| byte == &0) {
        return None;
    }
    let held = u64_at(state, TENURE_HELD_AT)?;
    let age_sum = u128_at(state, TENURE_AGE_SUM_AT)?;
    Some((held, age_sum))
}

/// `since` of a holding's Tenure record, in ticks. `None` when the 64 bytes do not start with
/// the tag: the holding has no record.
pub fn record_since(hook_data: &[u8; 64]) -> Option<u64> {
    if hook_data[0] != TENURE_TAG[0] || hook_data[1] != TENURE_TAG[1] || hook_data[2] != TENURE_TAG[2]
    {
        return None;
    }
    Some(u64::from_le_bytes([
        hook_data[8],
        hook_data[9],
        hook_data[10],
        hook_data[11],
        hook_data[12],
        hook_data[13],
        hook_data[14],
        hook_data[15],
    ]))
}

/// A holding's entry in the list: the first 8 bytes of its address, read as a u64.
pub fn list_key(address: &[u8; 32]) -> u64 {
    u64::from_le_bytes([
        address[0], address[1], address[2], address[3], address[4], address[5], address[6],
        address[7],
    ])
}

/// One entry of the list as a number.
fn entry_key(entry: &[u8]) -> Option<u64> {
    Some(u64::from_le_bytes(entry.try_into().ok()?))
}

/// Whether `key` is among the first `handled` entries of `list`. Entries after them are left
/// over from earlier rounds and are not read.
pub fn list_holds(list: &[u8], handled: u32, key: u64) -> bool {
    let in_use = usize::try_from(handled).unwrap_or(usize::MAX);
    list.chunks_exact(ENTRY_LEN)
        .take(in_use)
        .any(|entry| entry_key(entry) == Some(key))
}

/// Writes `key` as entry number `handled` of `list`. `false` when the list has no such entry.
pub fn list_put(list: &mut [u8], handled: u32, key: u64) -> bool {
    let at = usize::try_from(handled).unwrap_or(usize::MAX);
    match list.chunks_exact_mut(ENTRY_LEN).nth(at) {
        Some(entry) => {
            entry.copy_from_slice(&key.to_le_bytes());
            true
        }
        None => false,
    }
}
src/round.rs314 lines ›
//! The arithmetic of a round, on plain numbers. Nothing here reads an account or the clock, so
//! every function can be run on any machine and compared with the reference numbers.
//!
//! A round fixes three numbers when it opens: its time, its budget and the coin-seconds of all
//! holders at that time. A holding's part of the budget is its own coin-seconds over that total.
//! Coin-seconds are a balance in base units times a time in whole seconds, 30 days at most.
//!
//! Sums and products use the saturating calls, and every product is made in u128. A division
//! by zero answers the fallback written beside it.

use crate::layout::{list_holds, list_put, LIST_CAP};

/// Tenure's unit of `since`: 65,536 ticks are 1 second.
pub const TICKS_PER_SEC: u64 = 65_536;
/// The most time counted for a holding, 30 days, in ticks: 2,592,000 s x 65,536.
pub const AGE_CAP_TICKS: u64 = 169_869_312_000;
/// The largest clock value used, as in Tenure: the largest u32.
pub const LAST_SECOND: u64 = 4_294_967_295;
/// The least pot a round opens on: 0.15 SOL, in lamports. A budget is 90% of the pot, so no
/// round has a budget under 135,000,000 lamports. That is above 126,400,000, which is 1,264
/// times `MIN_SEND`, so the least amount of every round is its budget divided by 1,264, rounded
/// up. A smaller pot waits for more fees.
pub const POT_FLOOR: u64 = 150_000_000;
/// A round's budget is 9,000 of 10,000 parts of the pot: 90%.
pub const ROUND_SHARE_BPS: u64 = 9_000;
pub const BPS: u64 = 10_000;
/// The most one round's budget can be: 1,000 SOL. It is below 2^40, so a budget times the
/// coin-seconds of any holding, which are below 2^86, fits a u128.
pub const BUDGET_MAX: u64 = 1_000_000_000_000;
/// The least time between two openings: 1 hour. A round takes holdings for at least this long.
pub const ROUND_MIN_GAP: u64 = 3_600;
/// An open round is over at this age: 24 hours.
pub const ROUND_MAX_AGE: u64 = 86_400;
/// A round an hour old is over once `sent x 10 >= budget x 9`: once 90% of its budget was sent.
pub const CLOSE_NUM: u64 = 9;
pub const CLOSE_DEN: u64 = 10;
/// The least holding a step sends to: 100,000 coins of 6 decimals, in base units.
pub const MIN_HOLDING: u64 = 100_000_000_000;
/// The floor of a round's least amount: 0.0001 SOL, in lamports. It is five of the 20,000
/// lamports the companion pays the sender of a step. See [`least_send`].
pub const MIN_SEND: u64 = 100_000;
/// Holdings one step sends to at most.
pub const MAX_PER_STEP: u32 = 16;

/// The numbers of the state's header, as the rules use them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Round {
    /// Number of the round, 0 before the first.
    pub round: u64,
    /// When the round opened, in seconds.
    pub opened_at: u64,
    /// Lamports the round may send in all.
    pub budget: u64,
    /// Lamports the round has sent.
    pub sent: u64,
    /// Coin-seconds of all holders at `opened_at`.
    pub total: u128,
    /// Entries of the list in use in this round: one for each holding it has sent to.
    pub handled: u32,
    /// Transfers of bridged SOL made in this round. Equal to `handled`.
    pub transfers: u32,
    /// Lamports sent in all rounds.
    pub sent_total: u64,
}

/// One holding a step was given, as plain facts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Holder {
    /// The holding is of this coin.
    pub mint_matches: bool,
    /// Its owner is the all-zero key.
    pub owner_default: bool,
    /// Its owner is the companion's creator address, which holds the dev's coins.
    pub owner_is_creator: bool,
    /// Its owner is a point on the ed25519 curve: a wallet, not a program's address.
    pub owner_on_curve: bool,
    /// Its balance, in base units.
    pub amount: u64,
    /// `since` of its Tenure record, in ticks. `None`: it has no record.
    pub since: Option<u64>,
    /// Its entry in the list.
    pub key: u64,
}

/// Why a holding is passed over. It may be brought again in the same round.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Why {
    /// A holding of another coin.
    OtherMint,
    /// The owner is the all-zero key, the creator address, or not on the curve.
    NotAWallet,
    /// Under `MIN_HOLDING`.
    UnderLeastHolding,
    /// No Tenure record.
    NoRecord,
    /// Sent to already in this round.
    Handled,
    /// Under the round's least amount coming. A holding whose coins all arrived in the opening
    /// second or after it counts no time, so it is always here.
    UnderLeastAmount,
}

/// What a step does with one holding. There is no third answer: the only way into the list is
/// to be sent to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
    /// Nothing changes: nothing is sent and no entry is written.
    Skip(Why),
    /// It has this many lamports coming, the round's least amount or more. They are sent if it
    /// has a place to be sent to and the pot holds them, and only then does its key join the list.
    Send(u64),
}

/// The chain clock's seconds as Tenure uses them: not below 0, not above `LAST_SECOND`.
pub fn clock_seconds(unix_timestamp: i64) -> u64 {
    u64::try_from(unix_timestamp.max(0))
        .unwrap_or(0)
        .min(LAST_SECOND)
}

/// Whole seconds a holding's coins had been there when the round opened, 30 days at most.
/// Coins that arrived in the opening second or after it count 0.
pub fn age_seconds(opened_at: u64, since: u64) -> u64 {
    let opened_ticks = opened_at.saturating_mul(TICKS_PER_SEC);
    let ticks = opened_ticks.saturating_sub(since).min(AGE_CAP_TICKS);
    ticks.checked_div(TICKS_PER_SEC).unwrap_or(0)
}

/// A holding's coin-seconds in a round: base units times whole seconds. Below 2^86.
pub fn coin_seconds(amount: u64, since: u64, opened_at: u64) -> u128 {
    u128::from(amount).saturating_mul(u128::from(age_seconds(opened_at, since)))
}

/// The coin-seconds of all holders at `opened_at`, from Tenure's two totals, with the creator
/// address's own holding taken out. `creator_amount` and `creator_since` are both 0 when that
/// holding has no record or no coins.
///
/// It is the smaller of two sums: every balance times its time held, and every balance times
/// 30 days. A sum of the smaller of two is never above the smaller of two sums, so the result
/// is never below what the holdings come to one by one. It is rounded up to whole seconds while
/// each holding's own time is rounded down.
pub fn round_total(
    held: u64,
    age_sum: u128,
    opened_at: u64,
    creator_amount: u64,
    creator_since: u64,
) -> u128 {
    let opened_ticks = opened_at.saturating_mul(TICKS_PER_SEC);
    let uncapped = u128::from(held)
        .saturating_mul(u128::from(opened_ticks))
        .saturating_sub(age_sum);
    let capped = u128::from(held).saturating_mul(u128::from(AGE_CAP_TICKS));
    let creator_uncapped = u128::from(creator_amount)
        .saturating_mul(u128::from(opened_ticks.saturating_sub(creator_since)));
    let creator_capped = u128::from(creator_amount).saturating_mul(u128::from(AGE_CAP_TICKS));
    let total_ticks = uncapped
        .saturating_sub(creator_uncapped)
        .min(capped.saturating_sub(creator_capped));
    let whole = total_ticks
        .checked_div(u128::from(TICKS_PER_SEC))
        .unwrap_or(0);
    let left_over = total_ticks
        .checked_rem(u128::from(TICKS_PER_SEC))
        .unwrap_or(0);
    if left_over == 0 {
        whole
    } else {
        whole.saturating_add(1)
    }
}

/// A round's budget from the pot: 90% of it, rounded down, `BUDGET_MAX` at most.
pub fn budget_of(pot: u64) -> u64 {
    let share = u128::from(pot)
        .saturating_mul(u128::from(ROUND_SHARE_BPS))
        .checked_div(u128::from(BPS))
        .unwrap_or(0);
    u64::try_from(share).unwrap_or(u64::MAX).min(BUDGET_MAX)
}

/// A holding's part of the budget, rounded down: `budget x cs / total`. A quotient above the
/// largest u64 counts as the largest u64. A round with no coin-seconds gives 0.
pub fn share_of(budget: u64, cs: u128, total: u128) -> u64 {
    let product = u128::from(budget).saturating_mul(cs);
    match product.checked_div(total) {
        Some(quotient) => u64::try_from(quotient).unwrap_or(u64::MAX),
        None => 0,
    }
}

/// What a holding is owed: its part of the budget, never more than the budget has left.
pub fn send_of(budget: u64, cs: u128, total: u128, sent: u64) -> u64 {
    share_of(budget, cs, total).min(budget.saturating_sub(sent))
}

/// The least amount a round sends to one holding, in lamports: the larger of `MIN_SEND` and the
/// budget divided by the 1,264 entries of the list, rounded up. A holding with less coming in
/// the round is passed over: nothing is sent and no entry is written.
///
/// It is worked out from the round's budget alone, which changes only when a round opens, so
/// every step of a round uses the same number. Each entry of the list then stands for a send of
/// at least a 1,264th of the budget, and 1,264 such sends are the whole budget or more: the
/// list is full only when the budget is spent.
///
/// Rounded up as `(budget + 1,263) / 1,264`, rounded down. A budget is 10^12 at most, so the sum
/// is far below the largest u64. The divisor is a constant above 0; were it 0, the answer would
/// be the largest u64 and nothing would be sent.
pub fn least_send(budget: u64) -> u64 {
    let entries = u64::from(LIST_CAP);
    budget
        .saturating_add(entries.saturating_sub(1))
        .checked_div(entries)
        .unwrap_or(u64::MAX)
        .max(MIN_SEND)
}

/// Whether the round is over: there has been none, it is 24 hours old, or it is an hour old
/// and 90% of its budget was sent.
///
/// A round younger than an hour is never over. No new round could open before the hour, so the
/// one that is open goes on taking the holdings a step brings, also after 90% was sent: each
/// is still owed its own part, and never more than the budget has left.
pub fn round_over(round: &Round, now: u64) -> bool {
    if round.round == 0 {
        return true;
    }
    let age = now.saturating_sub(round.opened_at);
    if age >= ROUND_MAX_AGE {
        return true;
    }
    if age < ROUND_MIN_GAP {
        return false;
    }
    let sent_side = u128::from(round.sent).saturating_mul(u128::from(CLOSE_DEN));
    let budget_side = u128::from(round.budget).saturating_mul(u128::from(CLOSE_NUM));
    sent_side >= budget_side
}

/// Whether a new round may open: the pot holds `POT_FLOOR`, and this is the first round or the
/// last one opened an hour ago or more.
pub fn may_open(round: &Round, now: u64, pot: u64) -> bool {
    let gap_passed = round.round == 0 || now.saturating_sub(round.opened_at) >= ROUND_MIN_GAP;
    gap_passed && pot >= POT_FLOOR
}

/// Opens a round at `now` on `pot`, with `total` coin-seconds. The list is not cleared: with
/// `handled` at 0 none of its entries is read.
pub fn open_round(round: &mut Round, now: u64, pot: u64, total: u128) {
    round.round = round.round.saturating_add(1);
    round.opened_at = now;
    round.budget = budget_of(pot);
    round.sent = 0;
    round.total = total;
    round.handled = 0;
    round.transfers = 0;
}

/// Whether a step may send to one more holding: it has sent to fewer than `MAX_PER_STEP`, and
/// the list has an entry left.
pub fn step_has_room(sent_to: u32, handled: u32) -> bool {
    sent_to < MAX_PER_STEP && handled < LIST_CAP
}

/// What a step does with a holding of the token program that decoded. The tests, in this order:
/// of this coin; owned by a wallet; at least `MIN_HOLDING`; with a Tenure record; not sent to in
/// this round; the round's least amount or more coming. A holding that fails one is passed over.
pub fn share_for(holder: &Holder, round: &Round, list: &[u8]) -> Outcome {
    if !holder.mint_matches {
        return Outcome::Skip(Why::OtherMint);
    }
    if holder.owner_default || holder.owner_is_creator || !holder.owner_on_curve {
        return Outcome::Skip(Why::NotAWallet);
    }
    if holder.amount < MIN_HOLDING {
        return Outcome::Skip(Why::UnderLeastHolding);
    }
    let Some(since) = holder.since else {
        return Outcome::Skip(Why::NoRecord);
    };
    if list_holds(list, round.handled, holder.key) {
        return Outcome::Skip(Why::Handled);
    }
    let cs = coin_seconds(holder.amount, since, round.opened_at);
    let lamports = send_of(round.budget, cs, round.total, round.sent);
    if lamports < least_send(round.budget) {
        return Outcome::Skip(Why::UnderLeastAmount);
    }
    Outcome::Send(lamports)
}

/// Whether the pot, as read at the start of the step, still holds `lamports` after what this
/// step has sent so far.
pub fn pot_holds(lamports: u64, pot: u64, spent: u64) -> bool {
    lamports <= pot.saturating_sub(spent)
}

/// After a transfer of `lamports`: the round's sums follow.
pub fn note_sent(round: &mut Round, lamports: u64) {
    round.sent = round.sent.saturating_add(lamports);
    round.sent_total = round.sent_total.saturating_add(lamports);
    round.transfers = round.transfers.saturating_add(1);
}

/// Writes the key of a holding that was just sent to into the list and counts it. `false`, with
/// nothing changed, when the list is full.
pub fn record(round: &mut Round, list: &mut [u8], key: u64) -> bool {
    if round.handled >= LIST_CAP || !list_put(list, round.handled, key) {
        return false;
    }
    round.handled = round.handled.saturating_add(1);
    true
}
Tenure Pot · Workshop · Bordrfull