bond_servicing · 30 charts from 13 source files · revision e5ff2b3

How the program works, as its code runs

One chart per instruction and per rule the instructions share. Each runs top to bottom in the order of the code: first what Anchor checks on the accounts, then the program's own checks, calculations, calls into other programs, writes and events. Every box shows the code it stands for and links to that line; every check shows the error it refuses with. A refusal anywhere undoes the whole transaction.

start, end
Anchor account check
program check
calculation or write
calls another chart
calls another program
event in the log
How a call runsAny instruction

How a call runs

runtime

Any instruction

What happens from the moment a transaction names bond_servicing until its changes are kept or thrown away. Every chart below is one piece of this.

  1. a transaction calls bond_servicing with instruction data and a list of accounts

    lib.rs:21
    #[program]

  2. Anchor picks the instruction by the first 8 bytes of the data

    lib.rs:37
    #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)]

    transfer_hook answers the transfer-hook interface's Execute instruction, so Token-2022 can call it on every transfer

  3. Anchor checks every account the instruction lists, field by field in the order its accounts struct declares them, before any of the program's own code runs

    failsAccountNotSignerAccountOwnedByWrongProgramConstraintHasOneConstraintSeeds

    a wrong account (another bond's record, another event's vault, a missing signature) stops here

  4. the handler runs the program's checks and changes, top to bottom

  5. it calls Token-2022, SPL Token or the System program; where an authority is needed the Bond account signs, with its seeds ["bond", bond token, bump]

    an address derived from seeds has no private key: only bond_servicing can sign for it, and only with these seeds

  6. Ok: every change commits together. An error anywhere: the runtime undoes every change of the transaction, the other programs' token moves included

Issue

instruction

create_bond

sent by: Registrar

Creates the bond: its token, the Bond account with the terms and the time table, and the list of accounts the transfer hook needs, in one transaction. The program creates the token itself, so how the token is configured is set here, not trusted.

  1. create_bond(terms) is called

    lib.rs:25
    pub fn create_bond(ctx: Context<CreateBond>, terms: BondTerms) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct CreateBond<'info>create_bond.rs:59
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the registrar signed (it pays the new accounts' rent)

    create_bond.rs:61
    pub registrar: Signer<'info>,
    failsAccountNotSigner

  5. the new bond token's keypair signed: a fresh account

    create_bond.rs:65
    pub mint: Signer<'info>,
    failsAccountNotSigner

  6. creates the Bond account at ["bond", bond token]

    create_bond.rs:67
    #[account(
        init,
        payer = registrar,
        space = 8 + Bond::INIT_SPACE,
        seeds = [BOND_SEED, mint.key().as_ref()],
        bump,
    )]
    pub bond: Box<Account<'info, Bond>>,
    already existsSystem: account already in use

    one bond per token, at an address anyone can compute

  7. creates the hook's account list at ["extra-account-metas", bond token]

    create_bond.rs:79
    #[account(
        init,
        payer = registrar,
        space = extra_account_metas_size(),
        seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()],
        bump,
    )]
    pub extra_account_meta_list: UncheckedAccount<'info>,
    already existsSystem: account already in use

    Token-2022 reads this list on every transfer to know which accounts to pass to the hook

  8. Handlerpub fn handle_create_bondcreate_bond.rs:92
  9. check the terms and the payment schedule

    create_bond.rs:93
    validate_terms(&terms)?;
    see validate_terms
    failsInvalidTermsInvalidSchedule

  10. check the cash token

    create_bond.rs:94
    check_cash_mint(&ctx.accounts.cash_mint.to_account_info())?;
    see check_cash_mint
    failsUnsupportedCashToken

  11. create and configure the bond token

    create_bond.rs:95
    create_bond_mint(&ctx)?;
    see create_bond_mint

  12. write the hook's list: the Bond account and both holders' records, each holder record's address taken from its token account's owner

    create_bond.rs:96
    write_extra_account_metas(&ctx.accounts.extra_account_meta_list.to_account_info())?;

    so every transfer brings the sender's and the receiver's records to the hook

  13. store the registrar, the two tokens, the terms and the time table

    create_bond.rs:99
    bond.registrar = ctx.accounts.registrar.key();

  14. chain origin = the chain clock now: bond time starts at bond_origin from this moment

    create_bond.rs:114
    bond.chain_origin = Clock::get()?.unix_timestamp;

  15. supply 0; issue size = max_supply from the terms

    create_bond.rs:117
    bond.supply = PositionHistory::new(0);

  16. emit!(BondCreated {

  17. Ok: every change above commits together

check

validate_terms

Called by create_bond. The registrar computes payment and record days off-chain with the bond's holiday calendar; the program checks everything it can without the calendar.

  1. validate_terms(terms)

    create_bond.rs:193
    fn validate_terms(t: &BondTerms) -> Result<()> {

  2. face value, issue size, rate, redemption price, months per period and time scale above zero; record and payment times inside a day; at least one business weekday

    create_bond.rs:195
    require!(
        t.face_units > 0
            && t.max_supply > 0
            && t.rate_bps > 0
            && t.redemption_price_bps > 0
            && t.months_per_period > 0
    …
            && day.contains(&t.payment_seconds_local)
    failsInvalidTerms

  3. the coupon basis is consistent

    create_bond.rs:207
    match t.basis {
    fixed per period
    1. periods a year x months a period = 12

      create_bond.rs:209
      periods_per_year > 0 && periods_per_year * t.months_per_period == 12,
      failsInvalidTerms
    30/360
    1. convention 0, 1 or 2 (Bond Basis, European, USA)

      create_bond.rs:212
      CouponBasis::Thirty360 { convention } => require!(convention <= 2, BondError::InvalidTerms),
      failsInvalidTerms

  4. recompute the regular schedule backward from maturity, unadjusted

    create_bond.rs:218
    let no_holidays = Calendar::new(&[], &[]).map_err(|_| error!(BondError::InvalidTerms))?;
    failsInvalidTermsInvalidSchedule

    a schedule whose first period is not a whole period (a stub) is refused

  5. one payment day and one record day per period, at most 40 periods

    create_bond.rs:222
    periods.len() == t.payment_days.len() && periods.len() == t.record_days.len() && periods.len() <= MAX_PERIODS,
    failsInvalidSchedule

  6. business day = not one of the bond's weekend days

    create_bond.rs:225
    let business_day = |day: i32| t.weekend_days & (1 << Date::from_days(i64::from(day)).weekday() as u8) == 0;

  7. for each period: the payment day is within 14 days of the period's end and a business day; the record day is a business day; record moments strictly increase; the record moment is not after the payment moment

    create_bond.rs:227
    for ((p, &pay), &rec) in periods.iter().zip(&t.payment_days).zip(&t.record_days) {
        let shift = i64::from(pay) - p.accrual_end.days();
        let record_moment = i64::from(rec) * SECONDS_PER_DAY + i64::from(t.record_seconds_local);
        let payment_moment = i64::from(pay) * SECONDS_PER_DAY + i64::from(t.payment_seconds_local);
        require!(
            (-MAX_PAYMENT_DELAY_DAYS..=MAX_PAYMENT_DELAY_DAYS).contains(&shift)
    …
                && record_moment > previous_record
    failsInvalidSchedule

    a holder list cannot be fixed after the money is due, and every period has exactly one record moment

  8. return: the terms are accepted

check

check_cash_mint

Called by create_bond. The cash token must deliver exactly the amount sent, and only the program may move the cash in a vault.

  1. check_cash_mint(cash token)

    create_bond.rs:175
    fn check_cash_mint(info: &AccountInfo) -> Result<()> {

  2. a classic SPL Token mint?

    create_bond.rs:176
    if *info.owner == anchor_spl::token::ID {
    exityes: accepted (SPL Token has no extensions)

  3. otherwise it is a Token-2022 mint

    create_bond.rs:179
    require_keys_eq!(*info.owner, spl_token_2022::ID, BondError::UnsupportedCashToken);
    failsUnsupportedCashToken

  4. its extensions can be read

    create_bond.rs:181
    let mint = StateWithExtensions::<MintState>::unpack(&data).map_err(|_| error!(BondError::UnsupportedCashToken))?;
    failsUnsupportedCashToken

  5. no transfer fee, transfer hook, non-transferability or permanent delegate

    create_bond.rs:183
    let refused = [
        ExtensionType::TransferFeeConfig,
        ExtensionType::TransferHook,
        ExtensionType::NonTransferable,
        ExtensionType::PermanentDelegate,
    ];
    failsUnsupportedCashToken

    a fee or a hook would change what a holder receives; a permanent delegate could take a vault's cash without the program. The cash issuer's freeze and pause stay possible: they can delay a payment, not take it

  6. return: the cash token is accepted

function

create_bond_mint

Called by create_bond. Every authority over the bond token goes to the Bond account, which only the program can sign for.

  1. create_bond_mint

    create_bond.rs:132
    fn create_bond_mint(ctx: &Context<CreateBond>) -> Result<()> {

  2. size of a token with a permanent delegate, a transfer hook and permissioned burn

    create_bond.rs:137
    let space = ExtensionType::try_calculate_account_len::<MintState>(&[
        ExtensionType::PermanentDelegate,
        ExtensionType::TransferHook,
    ])
    failsInvalidTerms

  3. System program: create the token account, owned by Token-2022; the registrar pays the rent

    create_bond.rs:143
    system_program::create_account(
        CpiContext::new(
            ctx.accounts.system_program.key(),
            CreateAccount { from: ctx.accounts.registrar.to_account_info(), to: mint.clone() },
        ),
        Rent::get()?.minimum_balance(space),
    …
    )?;

  4. Token-2022: permanent delegate = Bond account

    create_bond.rs:156
    spl_token_2022::instruction::initialize_permanent_delegate(&spl_token_2022::ID, &mint_key, &bond_key)?,

    lets the program burn a holder's bonds when it pays for them (redemption, buyback, tender) without the holder signing

  5. Token-2022: transfer hook = bond_servicing

    create_bond.rs:157
    transfer_hook::instruction::initialize(&spl_token_2022::ID, &mint_key, Some(bond_key), Some(crate::ID))?,

    every transfer of the bond calls this program

  6. Token-2022: permissioned burn, authority = Bond account

    create_bond.rs:161
    data: permissioned_burn,

    holders cannot burn bonds themselves: every burn is the program's, paired with a payment

  7. Token-2022: the token itself, 0 decimals, mint authority = Bond account, no freeze authority

    create_bond.rs:163
    spl_token_2022::instruction::initialize_mint2(&spl_token_2022::ID, &mint_key, &bond_key, None, 0)?,

    only the program issues bonds; nobody can freeze a holder's bonds

  8. the four Token-2022 instructions run in this order, the token itself last

    create_bond.rs:165
    for ix in instructions.iter() {

  9. return

instruction

register_holder

sent by: Registrar

Admits a wallet as a holder (KYC is simulated). The transfer hook needs a holder record on both sides, so only registered wallets can hold the bond.

  1. register_holder(wallet) is called

    lib.rs:29
    pub fn register_holder(ctx: Context<RegisterHolder>, owner: Pubkey) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct RegisterHolder<'info>register_holder.rs:12
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the registrar signed

    register_holder.rs:14
    pub registrar: Signer<'info>,
    failsAccountNotSigner

  5. the signer is this bond's registrar

    register_holder.rs:16
    #[account(has_one = registrar)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

    only the registrar admits holders

  6. creates the holder record at ["holder", bond token, wallet]

    register_holder.rs:19
    #[account(
        init,
        payer = registrar,
        space = 8 + HolderRecord::INIT_SPACE,
        seeds = [HOLDER_SEED, bond.mint.as_ref(), owner.as_ref()],
        bump,
    )]
    pub holder: Box<Account<'info, HolderRecord>>,
    already existsSystem: account already in use

    one record per wallet and bond, created once

  7. Handlerpub fn handle_register_holderregister_holder.rs:31
  8. record moments already passed

    register_holder.rs:33
    let passed = bond.passed_record_moments(bond.now()?)?;
    see Bond time and record moments

  9. position: 0 at every record moment so far; live_from = record moments passed

    register_holder.rs:38
    holder.position = PositionHistory::new(passed);

    a holder registered late held nothing at earlier record moments

  10. no coupon paid, nothing locked, nothing redeemed

    register_holder.rs:39
    holder.paid = 0;

  11. HolderRegistered

    register_holder.rs:43
    emit!(HolderRegistered { bond: bond.key(), owner });

  12. Ok: every change above commits together

instruction

place

sent by: Registrar

Issues bonds to a registered holder, before the first record moment and up to the issue size.

  1. place(amount) is called

    lib.rs:33
    pub fn place(ctx: Context<Place>, amount: u64) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct Place<'info>place.rs:14
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the registrar signed

    place.rs:15
    pub registrar: Signer<'info>,
    failsAccountNotSigner

  5. this bond's registrar and this bond's token

    place.rs:17
    #[account(mut, has_one = registrar, has_one = mint)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

  6. the holder record of this bond, at its address

    place.rs:23
    #[account(mut, has_one = bond, seeds = [HOLDER_SEED, mint.key().as_ref(), holder.owner.as_ref()], bump = holder.bump)]
    pub holder: Box<Account<'info, HolderRecord>>,
    failsConstraintHasOneConstraintSeeds

  7. the holder's own bond token account

    place.rs:28
    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = holder.owner,
        associated_token::token_program = token_program,
    )]
    pub holder_bonds: Box<InterfaceAccount<'info, TokenAccount>>,
    failsConstraintAssociatedConstraintTokenOwner

  8. the real Token-2022 program

    place.rs:34
    pub token_program: Program<'info, Token2022>,
    failsInvalidProgramId

  9. Handlerpub fn handle_placeplace.rs:37
  10. bond time is before the first record moment

    place.rs:39
    require!(bond.now()? < bond.record_time(0)?, BondError::PlacementClosed);
    failsPlacementClosed

    the issue is complete before any coupon's holder list is fixed, so no position history needs saving here

  11. new supply = supply + amount

    place.rs:40
    let new_supply = bond.supply.quantity.checked_add(amount).ok_or(BondError::IssueTooLarge)?;
    failsIssueTooLarge

  12. new supply is at most the issue size

    place.rs:41
    require!(new_supply <= bond.max_supply, BondError::ExceedsIssueSize);
    failsExceedsIssueSize

    the registrar cannot issue more bonds than the terms allow

  13. every coupon and the principal on the new supply fit in a token amount

    place.rs:42
    bond.check_serviceable(new_supply)?;
    see check_serviceable
    failsIssueTooLarge

  14. Token-2022 MintTo: amount bonds into the holder's account; the Bond account signs as mint authority

    place.rs:45
    token_2022::mint_to(
        CpiContext::new_with_signer(
            ctx.accounts.token_program.key(),
            MintTo {

  15. holder's quantity + amount

    place.rs:60
    holder.position.quantity = holder.position.quantity.checked_add(amount).ok_or(BondError::Overflow)?;
    failsOverflow

  16. supply + amount

    place.rs:62
    bond.supply.quantity = bond.supply.quantity.checked_add(amount).ok_or(BondError::Overflow)?;
    failsOverflow

    the supply is counted by the program, so a coupon's budget never needs a count of holders

  17. BondsPlaced

    place.rs:63
    emit!(BondsPlaced { bond: bond.key(), owner: holder.owner, amount });

  18. Ok: every change above commits together

Every transfer

instruction

transfer_hook

sent by: Token-2022, inside a holder's transfer

Token-2022 calls this after it has moved the balances of every bond transfer, in the same transaction. Before the two holders' quantities change, each holder's quantity is kept for every record moment passed since its last change: this replaces the snapshot of holders.

  1. Token-2022 calls transfer_hook(amount)

    lib.rs:38
    pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {

  2. Accounts: passed by Token-2022 from the hook's listpub struct TransferHook<'info>transfer_hook.rs:25
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the source is an account of the bond token

    transfer_hook.rs:26
    #[account(token::mint = mint)]
    pub source_token: InterfaceAccount<'info, TokenAccount>,
    failsConstraintTokenMint

  5. the destination is an account of the bond token

    transfer_hook.rs:31
    #[account(token::mint = mint)]
    pub destination_token: InterfaceAccount<'info, TokenAccount>,
    failsConstraintTokenMint

  6. the hook's list of this bond token

    transfer_hook.rs:38
    #[account(seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()], bump)]
    pub extra_account_meta_list: UncheckedAccount<'info>,
    failsConstraintSeeds

  7. the Bond account of this bond token

    transfer_hook.rs:41
    #[account(seeds = [BOND_SEED, mint.key().as_ref()], bump = bond.bump)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintSeeds

  8. the sender's holder record address

    transfer_hook.rs:46
    #[account(mut, seeds = [HOLDER_SEED, mint.key().as_ref(), source_token.owner.as_ref()], bump)]
    pub source_holder: UncheckedAccount<'info>,
    failsConstraintSeeds

  9. the receiver's holder record address

    transfer_hook.rs:50
    #[account(mut, seeds = [HOLDER_SEED, mint.key().as_ref(), destination_token.owner.as_ref()], bump)]
    pub destination_holder: UncheckedAccount<'info>,
    failsConstraintSeeds

  10. Handlerpub fn handle_transfer_hooktransfer_hook.rs:54
  11. Token-2022 marked the source account as being transferred

    transfer_hook.rs:56
    check_is_transferring(&a.source_token)?;
    failsNotTransferring

    the hook runs only inside a real transfer: calling it directly to fake a change of position fails

  12. the source is its owner's associated account

    transfer_hook.rs:57
    check_associated(&a.source_token, &a.mint)?;
    failsNotAssociatedTokenAccount

    one bond account per holder, so one holder record is one position

  13. the destination is its owner's associated account

    transfer_hook.rs:58
    check_associated(&a.destination_token, &a.mint)?;
    failsNotAssociatedTokenAccount

  14. the sender's holder record exists (bond_servicing owns it)

    transfer_hook.rs:59
    let mut source = read_record(&a.source_holder.to_account_info())?;
    failsAccountOwnedByWrongProgram

    an unregistered wallet has no record: it can neither send nor receive the bond

  15. the receiver's holder record exists, unless the sender sends to itself

    transfer_hook.rs:61
    let mut destination = if same { None } else { Some(read_record(&a.destination_holder.to_account_info())?) };
    failsAccountOwnedByWrongProgram

  16. bond time now

    transfer_hook.rs:63
    let now = bond.now()?;
    see Bond time and record moments

  17. bond time is before the final record moment

    transfer_hook.rs:64
    require!(now < bond.final_record_time()?, BondError::TransfersClosedAtMaturity);
    failsTransfersClosedAtMaturity

    redemption pays on the holdings at the final record moment, so they cannot move after it

  18. the same account on both sides?

    transfer_hook.rs:65
    if same { return Ok(()); }
    exityes: nothing changes, return Ok

  19. record moments passed (searched from the later of the two holders' last changes)

    transfer_hook.rs:67
    let passed = bond.passed_record_moments_from(now, source.position.live_from.max(destination.position.live_from))?;
    see Bond time and record moments

  20. balances before the transfer: sender's now + amount, receiver's now - amount

    transfer_hook.rs:68
    let source_before = a.source_token.amount.checked_add(amount).ok_or(BondError::Overflow)?;
    failsOverflow

  21. sender: check, save the history, set the new quantity

    transfer_hook.rs:70
    update_record(&mut source, bond.key(), passed, source_before, a.source_token.amount)?;
    see update_record
    failsPositionMismatch

  22. the sender keeps at least its tendered bonds

    transfer_hook.rs:71
    require!(source.locked <= a.source_token.amount, BondError::BondsLocked);
    failsBondsLocked

    tendered bonds stay in the account and keep their coupons, but cannot leave it

  23. receiver: check, save the history, set the new quantity

    transfer_hook.rs:72
    update_record(destination, bond.key(), passed, destination_before, a.destination_token.amount)?;
    see update_record
    failsPositionMismatch

  24. write both holder records

    transfer_hook.rs:73
    source.try_serialize(&mut &mut a.source_holder.try_borrow_mut_data()?[..])?;

  25. Ok: the transfer completes with both records updated; any error undoes the transfer itself

function

update_record

Called by transfer_hook for the sender and the receiver.

  1. update_record(record, passed, before, after)

    transfer_hook.rs:83
    fn update_record(record: &mut HolderRecord, bond: Pubkey, passed: u16, before: u64, after: u64) -> Result<()> {

  2. the record's quantity equals the account's balance before the transfer

    transfer_hook.rs:84
    require!(record.position.quantity == before, BondError::PositionMismatch);
    failsPositionMismatch

    every balance change goes through the program, so they always agree; a disagreement means a change the program did not see, and the transfer is refused

  3. save the quantity for every record moment passed since the last change

    transfer_hook.rs:85
    if let Some((from_event, to_event)) = record.position.save(passed) {
    see Position history

  4. PositionsSaved: the holder held this quantity at those record moments

    transfer_hook.rs:86
    emit!(PositionsSaved { bond, owner: record.owner, from_event, to_event, quantity: before });

  5. quantity = the balance after the transfer

    transfer_hook.rs:88
    record.position.quantity = after;

  6. return

Coupons and redemption

instruction

open_event

sent by: Anyone

Opens a coupon or the redemption once its record moment has passed. Everything it writes follows from the terms and the supply history, so it does not matter who sends it.

  1. open_event(index) is called

    lib.rs:42
    pub fn open_event(ctx: Context<OpenEvent>, index: u16) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct OpenEvent<'info>open_event.rs:19
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. any wallet signed (it pays the rent)

    open_event.rs:21
    pub payer: Signer<'info>,
    failsAccountNotSigner

  5. the bond's own cash token

    open_event.rs:23
    #[account(has_one = cash_mint)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

  6. creates the event at ["event", bond, index]

    open_event.rs:29
    #[account(
        init,
        payer = payer,
        space = 8 + Event::INIT_SPACE,
        seeds = [EVENT_SEED, bond.key().as_ref(), &index.to_le_bytes()],
        bump,
    )]
    pub event: Box<Account<'info, Event>>,
    already existsSystem: account already in use

    each coupon and the redemption is opened once

  7. creates the event's vault at ["vault", event]: a cash account whose authority is the Bond account

    open_event.rs:40
    #[account(
        init,
        payer = payer,
        seeds = [VAULT_SEED, event.key().as_ref()],
        bump,
        token::mint = cash_mint,
    …
    pub vault: Box<InterfaceAccount<'info, TokenAccount>>,
    already existsSystem: account already in use

    each event has its own cash, so one coupon cannot spend another's

  8. Handlerpub fn handle_open_eventopen_event.rs:52
  9. the index is a coupon (0 to n-1) or the redemption (n)

    open_event.rs:54
    let period = bond.period_of(index)?;
    see Bond time and record moments
    failsEventOutOfRange

  10. its record moment

    open_event.rs:55
    let record_time = bond.record_time(index)?;
    see Bond time and record moments

  11. the record moment has passed

    open_event.rs:56
    require!(bond.now()? >= record_time, BondError::RecordMomentNotReached);
    failsRecordMomentNotReached

    the supply at the record moment is final only once that moment has passed

  12. supply at the record moment, from the supply history

    open_event.rs:59
    let supply = bond.supply.at_moment(period);
    see Position history

    no holder is counted

  13. the budget on the whole supply

    open_event.rs:60
    let budget = if is_redemption { bond.principal(supply)? } else { bond.coupon(supply, period)? };
    redemption
    1. principal(supply)

      open_event.rs:60
      let budget = if is_redemption { bond.principal(supply)? } else { bond.coupon(supply, period)? };
      see Coupon and principal
    coupon
    1. coupon(supply, period)

      open_event.rs:60
      let budget = if is_redemption { bond.principal(supply)? } else { bond.coupon(supply, period)? };
      see Coupon and principal

    rounded down once; each holder's amount is rounded down on its own, so the holders' amounts never add up to more than the budget

  14. store the kind, record moment, payment time, supply at the record moment, budget and vault; funded = false

    open_event.rs:66
    event.record_time = record_time;

  15. EventOpened

    open_event.rs:73
    emit!(EventOpened { bond: bond.key(), event: index, supply_at_record: supply, budget, vault: event.vault });

  16. Ok: every change above commits together

instruction

confirm_funding

sent by: Anyone

After the issuer has put cash into the event's vault (an ordinary token transfer), anyone confirms that it covers the budget. No signer is needed.

  1. confirm_funding is called

    lib.rs:46
    pub fn confirm_funding(ctx: Context<ConfirmFunding>) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct ConfirmFunding<'info>confirm_funding.rs:14
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the event at its address, and its own vault

    confirm_funding.rs:15
    #[account(mut, has_one = vault, seeds = [EVENT_SEED, event.bond.as_ref(), &event.index.to_le_bytes()], bump = event.bump)]
    pub event: Box<Account<'info, Event>>,
    failsConstraintHasOneConstraintSeeds

  5. Handlerpub fn handle_confirm_fundingconfirm_funding.rs:21
  6. not confirmed before

    confirm_funding.rs:23
    require!(!event.funded, BondError::AlreadyFunded);
    failsAlreadyFunded

  7. read the vault's balance

    confirm_funding.rs:24
    let vault_balance = ctx.accounts.vault.amount;

  8. the vault holds at least the budget

    confirm_funding.rs:25
    require!(vault_balance >= event.budget, BondError::InsufficientFunding);
    failsInsufficientFunding

    payment starts only when every holder can be paid

  9. event.funded = true;

  10. emit!(EventFunded { bond: event.bond, event: event.index, vault_balance, budget: event.budget });

  11. Ok: every change above commits together

instruction

pay_coupon

sent by: Anyone

Pays one holder one coupon, at any time after payment opens, in any order, sent by anyone: the amount comes from the holder's own record and the terms, and the cash can only go to the holder.

  1. pay_coupon is called

    lib.rs:50
    pub fn pay_coupon(ctx: Context<PayCoupon>) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct PayCoupon<'info>pay_coupon.rs:19
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. any wallet signed (it pays the fee)

    pay_coupon.rs:21
    pub payer: Signer<'info>,
    failsAccountNotSigner

  5. the bond's own cash token

    pay_coupon.rs:23
    #[account(has_one = cash_mint)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

  6. an event of this bond, at its address, with its own vault

    pay_coupon.rs:26
    #[account(has_one = bond, has_one = vault, seeds = [EVENT_SEED, bond.key().as_ref(), &event.index.to_le_bytes()], bump = event.bump)]
    pub event: Box<Account<'info, Event>>,
    failsConstraintHasOneConstraintSeeds

    another event's vault is refused

  7. the holder record of this wallet and bond

    pay_coupon.rs:29
    #[account(mut, has_one = bond, has_one = owner, seeds = [HOLDER_SEED, bond.mint.as_ref(), owner.key().as_ref()], bump = holder.bump)]
    pub holder: Box<Account<'info, HolderRecord>>,
    failsConstraintHasOneConstraintSeeds

  8. the wallet's own associated cash account, created if missing

    pay_coupon.rs:41
    #[account(
        init_if_needed,
        payer = payer,
        associated_token::mint = cash_mint,
        associated_token::authority = owner,
        associated_token::token_program = cash_token_program,
    )]
    pub holder_cash: Box<InterfaceAccount<'info, TokenAccount>>,
    failsConstraintAssociatedConstraintTokenOwner

    whoever sends the transaction, the coupon can only go to the holder

  9. Handlerpub fn handle_pay_couponpay_coupon.rs:57
  10. a coupon, not the redemption

    pay_coupon.rs:60
    require!(event.kind == EventKind::Coupon, BondError::WrongEventKind);
    failsWrongEventKind

  11. the event is confirmed funded

    pay_coupon.rs:61
    require!(event.funded, BondError::NotFunded);
    failsNotFunded

  12. bond time has reached the payment time

    pay_coupon.rs:62
    require!(bond.now()? >= event.payment_time, BondError::PaymentNotOpen);
    failsPaymentNotOpen

  13. coupon e not paid to this holder yet

    pay_coupon.rs:64
    require!(!ctx.accounts.holder.is_paid(e), BondError::AlreadyPaid);
    failsAlreadyPaid

  14. the holder's position at record moment e

    pay_coupon.rs:65
    let position = ctx.accounts.holder.position.at_moment(e);
    see Position history

    what it held at the record moment, whatever it holds now

  15. the coupon on that position

    pay_coupon.rs:66
    let amount = bond.coupon(position, e)?;
    see Coupon and principal

  16. something is owed

    pay_coupon.rs:67
    require!(amount > 0, BondError::NothingToPay);
    failsNothingToPay

  17. pay from the event's vault to the holder's cash account

    pay_coupon.rs:72
    pay_cash(
        &Cash {
    see pay_cash
    failsMemoProgramMissing

  18. mark coupon e paid

    pay_coupon.rs:88
    holder.paid |= 1u64 << e;

    in the same transaction as the payment: both happen or neither

  19. emit!(CouponPaid { bond: ctx.accounts.bond.key(), event: e, owner: holder.owner, position, amount });

  20. Ok: every change above commits together

instruction

redeem_holder

sent by: Anyone

At maturity, burns one holder's bonds and pays their principal in one transaction: no bond is burned unpaid and no principal is paid for a live bond.

  1. redeem_holder is called

    lib.rs:54
    pub fn redeem_holder(ctx: Context<RedeemHolder>) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct RedeemHolder<'info>redeem_holder.rs:23
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. any wallet signed (it pays the fee)

    redeem_holder.rs:25
    pub payer: Signer<'info>,
    failsAccountNotSigner

  5. the bond's own tokens

    redeem_holder.rs:27
    #[account(mut, has_one = cash_mint, has_one = mint)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

  6. an event of this bond, at its address, with its own vault

    redeem_holder.rs:33
    #[account(has_one = bond, has_one = vault, seeds = [EVENT_SEED, bond.key().as_ref(), &event.index.to_le_bytes()], bump = event.bump)]
    pub event: Box<Account<'info, Event>>,
    failsConstraintHasOneConstraintSeeds

  7. the holder record of this wallet and bond

    redeem_holder.rs:36
    #[account(mut, has_one = bond, has_one = owner, seeds = [HOLDER_SEED, mint.key().as_ref(), owner.key().as_ref()], bump = holder.bump)]
    pub holder: Box<Account<'info, HolderRecord>>,
    failsConstraintHasOneConstraintSeeds

  8. the wallet's own bond account

    redeem_holder.rs:44
    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = owner,
        associated_token::token_program = token_program,
    )]
    pub holder_bonds: Box<InterfaceAccount<'info, TokenAccount>>,
    failsConstraintAssociatedConstraintTokenOwner

  9. the wallet's own cash account, created if missing

    redeem_holder.rs:56
    #[account(
        init_if_needed,
        payer = payer,
        associated_token::mint = cash_mint,
        associated_token::authority = owner,
        associated_token::token_program = cash_token_program,
    )]
    pub holder_cash: Box<InterfaceAccount<'info, TokenAccount>>,
    failsConstraintAssociatedConstraintTokenOwner

  10. the real Token-2022 program

    redeem_holder.rs:67
    pub token_program: Program<'info, Token2022>,
    failsInvalidProgramId

  11. Handlerpub fn handle_redeem_holderredeem_holder.rs:73
  12. the redemption event

    redeem_holder.rs:75
    require!(a.event.kind == EventKind::Redemption, BondError::WrongEventKind);
    failsWrongEventKind

  13. the redemption is confirmed funded

    redeem_holder.rs:76
    require!(a.event.funded, BondError::NotFunded);
    failsNotFunded

  14. bond time has reached the payment time

    redeem_holder.rs:77
    require!(a.bond.now()? >= a.event.payment_time, BondError::PaymentNotOpen);
    failsPaymentNotOpen

  15. every record moment has passed

    redeem_holder.rs:78
    let passed = a.bond.periods();

  16. the record's quantity equals the bond balance

    redeem_holder.rs:79
    let position = a.holder.position.quantity;
    failsPositionMismatch

  17. free = quantity - bonds accepted by a tender and not yet bought

    redeem_holder.rs:81
    let free = position.checked_sub(a.holder.locked).ok_or(BondError::Overflow)?;
    failsOverflow

    those are left to their offer, which pays for them

  18. something to redeem

    redeem_holder.rs:82
    require!(free > 0, BondError::NothingToPay);
    failsNothingToPay

  19. redeemed so far; after = before + free

    redeem_holder.rs:83
    let before = a.holder.redeemed;
    failsOverflow

  20. principal = principal(after) - principal(before)

    redeem_holder.rs:85
    let principal = a.bond.principal(after)? - a.bond.principal(before)?;
    see Coupon and principal

    several partial redemptions round exactly like one

  21. burn the free bonds

    redeem_holder.rs:92
    burn_bonds(
        &a.token_program.to_account_info(),
        &a.holder_bonds.to_account_info(),
        &a.mint.to_account_info(),
        &bond_info,
        free,
        seeds,
    )?;
    see burn_bonds

  22. pay the principal from the redemption vault

    redeem_holder.rs:101
    pay_cash(
        &Cash {
    see pay_cash
    failsMemoProgramMissing

  23. save both histories, then take the bonds off the holder and the supply

    redeem_holder.rs:115
    retire_quantity(&mut a.holder, &mut a.bond, bond_key, passed, free)?;
    see retire_quantity
    failsOverflow

    the final coupon stays payable whether it is paid before or after

  24. redeemed = after

    redeem_holder.rs:116
    a.holder.redeemed = after;

  25. emit!(BondsRedeemed { bond: bond_key, owner: a.holder.owner, burned: free, principal });

  26. Ok: every change above commits together

Buyback and tender

instruction

create_offer

sent by: Registrar

The issuer offers to buy bonds back at a price, up to a maximum, during a window: a buyback (first come) or a tender offer (pro rata).

  1. create_offer(id, kind, price, maximum, window) is called

    lib.rs:58
    pub fn create_offer(

  2. Accounts: Anchor checks these firstpub struct CreateOffer<'info>offer.rs:29
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the registrar signed

    offer.rs:31
    pub registrar: Signer<'info>,
    failsAccountNotSigner

  5. this bond's registrar and cash token

    offer.rs:33
    #[account(has_one = registrar, has_one = cash_mint)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

  6. creates the offer at ["offer", bond, id]

    offer.rs:39
    #[account(
        init,
        payer = registrar,
        space = 8 + Offer::INIT_SPACE,
        seeds = [OFFER_SEED, bond.key().as_ref(), &id.to_le_bytes()],
        bump,
    )]
    pub offer: Box<Account<'info, Offer>>,
    already existsSystem: account already in use

  7. creates the offer's vault at ["vault", offer]: authority = Bond account

    offer.rs:50
    #[account(
        init,
        payer = registrar,
        seeds = [VAULT_SEED, offer.key().as_ref()],
        bump,
        token::mint = cash_mint,
    …
    pub vault: Box<InterfaceAccount<'info, TokenAccount>>,
    already existsSystem: account already in use

  8. Handlerpub fn handle_create_offeroffer.rs:62
  9. price and maximum above zero; the window opens before it closes and closes by the final record moment; price x maximum fits in a token amount

    offer.rs:72
    require!(
        price_units > 0
            && max_quantity > 0
            && open_time < close_time
            && close_time <= bond.final_record_time()?
            && price_units.checked_mul(max_quantity).is_some(),
        BondError::InvalidOffer
    );
    failsInvalidOffer

    an offer must end while bonds can still move

  10. buyback (first come) or tender offer (pro rata)

    offer.rs:83
    offer.kind = if pro_rata { OfferKind::ProRata } else { OfferKind::FirstCome };

  11. nothing bought or tendered, not funded, no tenders

    offer.rs:88
    offer.total = 0;

  12. OfferCreated

    offer.rs:95
    emit!(OfferCreated { bond: bond.key(), offer: id, pro_rata, price_units, max_quantity, open_time, close_time });

  13. Ok: every change above commits together

instruction

confirm_offer_funding

sent by: Anyone

Anyone confirms that the offer's vault holds price x maximum. Only then may holders sell or tender.

  1. confirm_offer_funding is called

    lib.rs:70
    pub fn confirm_offer_funding(ctx: Context<ConfirmOfferFunding>) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct ConfirmOfferFunding<'info>offer.rs:100
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the offer at its address, and its own vault

    offer.rs:101
    #[account(mut, has_one = vault, seeds = [OFFER_SEED, offer.bond.as_ref(), &offer.id.to_le_bytes()], bump = offer.bump)]
    pub offer: Box<Account<'info, Offer>>,
    failsConstraintHasOneConstraintSeeds

  5. Handlerpub fn handle_confirm_offer_fundingoffer.rs:107
  6. not confirmed before

    offer.rs:109
    require!(!offer.funded, BondError::AlreadyFunded);
    failsAlreadyFunded

  7. budget = price x maximum

    offer.rs:110
    let budget = offer.budget().ok_or(BondError::Overflow)?;
    failsOverflow

  8. the vault holds at least the budget

    offer.rs:112
    require!(vault_balance >= budget, BondError::InsufficientFunding);
    failsInsufficientFunding

    holders sell or tender only into an offer that can pay for all of it

  9. funded = true

    offer.rs:113
    offer.funded = true;

  10. OfferFunded

    offer.rs:114
    emit!(OfferFunded { bond: offer.bond, offer: offer.id, vault_balance, budget });

  11. Ok: every change above commits together

accounts

Retirement accounts

The accounts sell_to_offer and settle_tender share: whose bonds are burned and where the cash goes.

  1. Accounts: Anchor checks these first, in this orderpub struct Retirement<'info>offer.rs:123
  2. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  3. a wallet signed (it pays the fee)

    offer.rs:125
    pub payer: Signer<'info>,
    failsAccountNotSigner

  4. the bond's own tokens

    offer.rs:127
    #[account(mut, has_one = mint, has_one = cash_mint)]
    pub bond: Box<Account<'info, Bond>>,
    failsConstraintHasOne

  5. the holder record of this wallet and bond

    offer.rs:133
    #[account(mut, has_one = bond, has_one = owner, seeds = [HOLDER_SEED, mint.key().as_ref(), owner.key().as_ref()], bump = holder.bump)]
    pub holder: Box<Account<'info, HolderRecord>>,
    failsConstraintHasOneConstraintSeeds

  6. the wallet's own bond account

    offer.rs:141
    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = owner,
        associated_token::token_program = token_program,
    )]
    pub holder_bonds: Box<InterfaceAccount<'info, TokenAccount>>,
    failsConstraintAssociatedConstraintTokenOwner

  7. the wallet's own cash account, created if missing

    offer.rs:153
    #[account(
        init_if_needed,
        payer = payer,
        associated_token::mint = cash_mint,
        associated_token::authority = owner,
        associated_token::token_program = cash_token_program,
    )]
    pub holder_cash: Box<InterfaceAccount<'info, TokenAccount>>,
    failsConstraintAssociatedConstraintTokenOwner

    the cash can only go to the holder whose bonds are burned

  8. the real Token-2022 program

    offer.rs:164
    pub token_program: Program<'info, Token2022>,
    failsInvalidProgramId

  9. the instruction's own accounts follow

instruction

sell_to_offer

sent by: Holder

A holder sells bonds into a buyback: burned and paid for in one transaction, first come, first served until the maximum.

  1. sell_to_offer(quantity) is called

    lib.rs:74
    pub fn sell_to_offer(ctx: Context<SellToOffer>, quantity: u64) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct SellToOffer<'info>offer.rs:212
  3. the retirement accounts

    offer.rs:213
    pub retirement: Retirement<'info>,
    see Retirement accounts

  4. the signer is the holder whose bonds are sold

    offer.rs:215
    #[account(constraint = seller.key() == retirement.owner.key())]
    pub seller: Signer<'info>,
    failsAccountNotSignerConstraintRaw

  5. an offer of this bond, at its address, paying from its own vault

    offer.rs:220
    #[account(
        mut,
        constraint = offer.bond == retirement.bond.key() && offer.vault == retirement.vault.key(),
        seeds = [OFFER_SEED, retirement.bond.key().as_ref(), &offer.id.to_le_bytes()],
        bump = offer.bump,
    )]
    pub offer: Box<Account<'info, Offer>>,
    failsConstraintRawConstraintSeeds

  6. Handlerpub fn handle_sell_to_offeroffer.rs:227
  7. bond time now

    offer.rs:229
    let now = a.retirement.bond.now()?;
    see Bond time and record moments

  8. a buyback (first come)

    offer.rs:230
    require!(a.offer.kind == OfferKind::FirstCome, BondError::WrongOfferKind);
    failsWrongOfferKind

  9. the offer is confirmed funded

    offer.rs:231
    require!(a.offer.funded, BondError::OfferNotFunded);
    failsOfferNotFunded

  10. inside the window

    offer.rs:232
    require!(a.offer.open_time <= now && now < a.offer.close_time, BondError::OfferNotOpen);
    failsOfferNotOpen

  11. total = bought so far + quantity

    offer.rs:233
    let total = a.offer.total.checked_add(quantity).ok_or(BondError::Overflow)?;
    failsOverflow

  12. quantity above zero and the total at most the maximum

    offer.rs:234
    require!(quantity > 0 && total <= a.offer.max_quantity, BondError::ExceedsOffer);
    failsExceedsOffer

  13. at most the holder's untendered bonds

    offer.rs:235
    require!(quantity <= a.retirement.holder.unlocked(), BondError::InsufficientUnlocked);
    failsInsufficientUnlocked

  14. offer total = total

    offer.rs:236
    a.offer.total = total;

  15. burn the bonds and pay price x quantity

    offer.rs:237
    buy_back(&mut a.retirement, a.offer.id, a.offer.price_units, quantity)?;
    see buy_back
    failsPositionMismatchOverflow

  16. Ok: every change above commits together

function

buy_back

Called by sell_to_offer and settle_tender.

  1. buy_back(quantity, price)

    offer.rs:172
    fn buy_back(r: &mut Retirement, offer_id: u16, price_units: u64, quantity: u64) -> Result<u64> {

  2. the record's quantity equals the bond balance

    offer.rs:173
    require!(r.holder_bonds.amount == r.holder.position.quantity, BondError::PositionMismatch);
    failsPositionMismatch

  3. paid = quantity x price

    offer.rs:174
    let paid = quantity.checked_mul(price_units).ok_or(BondError::Overflow)?;
    failsOverflow

  4. record moments passed

    offer.rs:175
    let passed = r.bond.passed_record_moments(r.bond.now()?)?;
    see Bond time and record moments

  5. burn the quantity

    offer.rs:181
    burn_bonds(
        &r.token_program.to_account_info(),
        &r.holder_bonds.to_account_info(),
        &r.mint.to_account_info(),
        &bond_info,
        quantity,
        seeds,
    )?;
    see burn_bonds

  6. pay from the offer's vault

    offer.rs:190
    pay_cash(
        &Cash {
    see pay_cash
    failsMemoProgramMissing

  7. save both histories, then take the bonds off the holder and the supply

    offer.rs:204
    retire_quantity(&mut r.holder, &mut r.bond, bond_key, passed, quantity)?;
    see retire_quantity
    failsOverflow

    a coupon whose record moment has passed is still paid on the bonds sold after it

  8. BondsBoughtBack

    offer.rs:205
    emit!(BondsBoughtBack { bond: bond_key, offer: offer_id, owner: r.holder.owner, burned: quantity, paid });

  9. return

instruction

change_tender

sent by: Holder

A holder tenders bonds into a tender offer, or withdraws them, while the window is open. The bonds do not move: they are locked in the holder's account.

  1. change_tender(quantity, withdraw) is called

    lib.rs:78
    pub fn change_tender(ctx: Context<ChangeTender>, quantity: u64, withdraw: bool) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct ChangeTender<'info>offer.rs:244
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. the holder signed

    offer.rs:246
    pub owner: Signer<'info>,
    failsAccountNotSigner

  5. an offer of this bond, at its address

    offer.rs:250
    #[account(mut, has_one = bond, seeds = [OFFER_SEED, bond.key().as_ref(), &offer.id.to_le_bytes()], bump = offer.bump)]
    pub offer: Box<Account<'info, Offer>>,
    failsConstraintHasOneConstraintSeeds

  6. the signer's own holder record

    offer.rs:253
    #[account(mut, has_one = bond, has_one = owner, seeds = [HOLDER_SEED, bond.mint.as_ref(), owner.key().as_ref()], bump = holder.bump)]
    pub holder: Box<Account<'info, HolderRecord>>,
    failsConstraintHasOneConstraintSeeds

  7. its tender at ["tender", offer, wallet], created at its first tender

    offer.rs:257
    #[account(
        init_if_needed,
        payer = owner,
        space = 8 + Tender::INIT_SPACE,
        seeds = [TENDER_SEED, offer.key().as_ref(), owner.key().as_ref()],
        bump,
    )]
    pub tender: Box<Account<'info, Tender>>,
    failsConstraintSeeds

  8. Handlerpub fn handle_change_tenderoffer.rs:270
  9. bond time now

    offer.rs:272
    let now = a.bond.now()?;
    see Bond time and record moments

  10. a tender offer (pro rata)

    offer.rs:273
    require!(a.offer.kind == OfferKind::ProRata, BondError::WrongOfferKind);
    failsWrongOfferKind

  11. the offer is confirmed funded

    offer.rs:274
    require!(a.offer.funded, BondError::OfferNotFunded);
    failsOfferNotFunded

  12. inside the window

    offer.rs:275
    require!(a.offer.open_time <= now && now < a.offer.close_time, BondError::OfferNotOpen);
    failsOfferNotOpen

  13. quantity above zero

    offer.rs:276
    require!(quantity > 0, BondError::ExceedsOffer);
    failsExceedsOffer

  14. the holder's first tender in this offer?

    offer.rs:278
    if tender.offer == Pubkey::default() {
    first
    1. its number = tenders made so far; the offer counts one more

      offer.rs:281
      tender.number = a.offer.tenders;
      failsOverflow
    later

      nothing else

    the number fixes the order in which tenders are resolved

  15. withdraw or tender more?

    offer.rs:289
    if withdraw {
    withdraw
    1. at most what it tendered

      offer.rs:290
      require!(quantity <= tender.quantity, BondError::ExceedsOffer);
      failsExceedsOffer
    2. tender, lock and offer total - quantity

      offer.rs:291
      tender.quantity -= quantity;
    tender
    1. at most its untendered bonds

      offer.rs:295
      require!(quantity <= a.holder.unlocked(), BondError::InsufficientUnlocked);
      failsInsufficientUnlocked
    2. tender, lock and offer total + quantity

      offer.rs:296
      tender.quantity = tender.quantity.checked_add(quantity).ok_or(BondError::Overflow)?;
      failsOverflow

    the lock keeps tendered bonds in the holder's account, so a coupon whose record moment falls inside the window is still paid on them

  16. TenderChanged

    offer.rs:300
    emit!(TenderChanged {

  17. Ok: every change above commits together

instruction

resolve_tender

sent by: Anyone

After the window, fixes one tender's accepted part and releases the rest. No cash and no token account are involved, so the rejected bonds are free again even if the cash cannot move. No signer is needed.

  1. resolve_tender is called

    lib.rs:82
    pub fn resolve_tender(ctx: Context<ResolveTender>) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct ResolveTender<'info>offer.rs:328
  3. every typed account belongs to the expected program and has the expected type

    failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramId

  4. an offer of this bond, at its address

    offer.rs:331
    #[account(mut, has_one = bond, seeds = [OFFER_SEED, bond.key().as_ref(), &offer.id.to_le_bytes()], bump = offer.bump)]
    pub offer: Box<Account<'info, Offer>>,
    failsConstraintHasOneConstraintSeeds

  5. a holder record of this bond, at its address

    offer.rs:334
    #[account(mut, has_one = bond, seeds = [HOLDER_SEED, bond.mint.as_ref(), holder.owner.as_ref()], bump = holder.bump)]
    pub holder: Box<Account<'info, HolderRecord>>,
    failsConstraintHasOneConstraintSeeds

  6. that holder's tender in this offer

    offer.rs:337
    #[account(mut, seeds = [TENDER_SEED, offer.key().as_ref(), holder.owner.as_ref()], bump = tender.bump)]
    pub tender: Box<Account<'info, Tender>>,
    failsConstraintSeeds

  7. Handlerpub fn handle_resolve_tenderoffer.rs:343
  8. a tender offer (pro rata)

    offer.rs:345
    require!(a.offer.kind == OfferKind::ProRata, BondError::WrongOfferKind);
    failsWrongOfferKind

  9. the window has closed

    offer.rs:346
    require!(a.bond.now()? >= a.offer.close_time, BondError::OfferStillOpen);
    failsOfferStillOpen

  10. resolve this tender

    offer.rs:347
    resolve(a.bond.key(), &mut a.offer, &mut a.tender, &mut a.holder)
    see resolve
    failsTenderOutOfOrderOverflow

  11. Ok: every change above commits together

function

resolve

Called by resolve_tender, and by settle_tender when nobody has resolved the tender yet.

  1. resolve(offer, tender, holder)

    offer.rs:313
    fn resolve(bond_key: Pubkey, offer: &mut Offer, tender: &mut Tender, holder: &mut HolderRecord) -> Result<()> {

  2. already resolved?

    offer.rs:314
    if tender.resolved {
    exityes: left as it is, return

  3. this tender's number is the next to resolve

    offer.rs:317
    require!(tender.number == offer.resolved, BondError::TenderOutOfOrder);
    failsTenderOutOfOrder

    tenders are resolved in the order they were made, so nobody gains by choosing when to resolve

  4. the accepted part

    offer.rs:318
    let accepted = offer.accept_next(tender.quantity).ok_or(BondError::Overflow)?;
    see Offer.accept_next
    failsOverflow

  5. rejected = tendered - accepted: unlocked at once

    offer.rs:320
    holder.locked = holder.locked.checked_sub(rejected).ok_or(BondError::Overflow)?;
    failsOverflow

  6. accepted is fixed; resolved = true

    offer.rs:321
    tender.accepted = accepted;

  7. TenderResolved

    offer.rs:323
    emit!(TenderResolved { bond: bond_key, offer: offer.id, owner: holder.owner, accepted, rejected });

  8. return

instruction

settle_tender

sent by: Anyone

After the window, buys one tender's accepted part: burned and paid for in one transaction, and released from the lock.

  1. settle_tender is called

    lib.rs:86
    pub fn settle_tender(ctx: Context<SettleTender>) -> Result<()> {

  2. Accounts: Anchor checks these firstpub struct SettleTender<'info>offer.rs:351
  3. the retirement accounts

    offer.rs:352
    pub retirement: Retirement<'info>,
    see Retirement accounts

  4. an offer of this bond, at its address, paying from its own vault

    offer.rs:357
    #[account(
        mut,
        constraint = offer.bond == retirement.bond.key() && offer.vault == retirement.vault.key(),
        seeds = [OFFER_SEED, retirement.bond.key().as_ref(), &offer.id.to_le_bytes()],
        bump = offer.bump,
    )]
    pub offer: Box<Account<'info, Offer>>,
    failsConstraintRawConstraintSeeds

  5. the tender of the holder whose bonds are bought

    offer.rs:365
    #[account(
        mut,
        seeds = [TENDER_SEED, offer.key().as_ref(), retirement.owner.key().as_ref()],
        bump = tender.bump,
    )]
    pub tender: Box<Account<'info, Tender>>,
    failsConstraintSeeds

  6. Handlerpub fn handle_settle_tenderoffer.rs:373
  7. a tender offer (pro rata)

    offer.rs:375
    require!(a.offer.kind == OfferKind::ProRata, BondError::WrongOfferKind);
    failsWrongOfferKind

  8. the window has closed

    offer.rs:376
    require!(a.retirement.bond.now()? >= a.offer.close_time, BondError::OfferStillOpen);
    failsOfferStillOpen

  9. resolve it first if nobody has

    offer.rs:377
    resolve(a.retirement.bond.key(), &mut a.offer, &mut a.tender, &mut a.retirement.holder)?;
    see resolve
    failsTenderOutOfOrderOverflow

  10. not settled before

    offer.rs:378
    require!(!a.tender.settled, BondError::AlreadySettled);
    failsAlreadySettled

  11. release the accepted part from the lock; settled = true

    offer.rs:380
    a.retirement.holder.locked = a.retirement.holder.locked.checked_sub(accepted).ok_or(BondError::Overflow)?;
    failsOverflow

  12. anything accepted?

    offer.rs:382
    if accepted > 0 {
    exitno: done, return Ok

  13. burn the accepted bonds and pay price x accepted

    offer.rs:383
    buy_back(&mut a.retirement, a.offer.id, a.offer.price_units, accepted)?;
    see buy_back
    failsPositionMismatchOverflow

  14. Ok: every change above commits together

Rules the instructions use

rule

Bond time and record moments

Every date rule compares bond time, read from the chain clock. Nobody passes a time in.

  1. period_of(event)

    state.rs:119
    pub fn period_of(&self, event: u16) -> Result<u16> {

  2. the event exists: coupons 0 to n-1, the redemption n

    state.rs:120
    require!(event <= self.redemption_event(), BondError::EventOutOfRange);
    failsEventOutOfRange

  3. the redemption uses the final coupon period's dates

    state.rs:121
    Ok(if event == self.redemption_event() { event - 1 } else { event })

  4. now(): read the chain clock (Clock sysvar)

    state.rs:125
    let chain_now = Clock::get()?.unix_timestamp;

    the cluster's time; no instruction argument can change it

  5. bond time = bond origin + (chain time - chain origin) x 86,400 / chain seconds per bond day

    state.rs:126
    chain_to_bond_time(chain_now, self.chain_origin, self.bond_origin, self.chain_seconds_per_bond_day)
    failsOverflow

    86,400 is real time; the devnet runs compress a bond day into a few chain seconds

  6. a local moment = day x 86,400 + seconds after midnight - the bond's UTC offset

    state.rs:131
    i64::from(day) * SECONDS_PER_DAY + i64::from(seconds_local) - i64::from(self.utc_offset_seconds)

    09:00 in Almaty (UTC+5) is 04:00 UTC

  7. record moment of an event = its period's record day at the record time

    state.rs:137
    Ok(self.local_moment(self.record_days[period as usize], self.record_seconds_local))

  8. payment opens on the payment day at the payment time

    state.rs:143
    Ok(self.local_moment(self.payment_days[period as usize], self.payment_seconds_local))

  9. final record moment = the last coupon's record moment

    state.rs:148
    self.record_time(self.periods() - 1)

    transfers stop here; offers must close by it

  10. record moments passed = how many record moments are at or before now (a binary search starting from the holder's last change)

    state.rs:164
    let passed = self.record_days[lo..].partition_point(|day| self.local_moment(*day, self.record_seconds_local) <= bond_now);

  11. return

rule

Position history

How the program knows what a holder held at a record moment without a snapshot. Every change to a holding (transfer, sale, tender settlement, redemption) calls save first. A holder record keeps the quantity now, live_from (how many record moments had passed at its last change) and at[e] for each record moment e. The bond's supply has the same history.

  1. save(passed): before every change

    state.rs:48
    pub fn save(&mut self, passed: u16) -> Option<(u16, u16)> {

  2. no record moment passed since the last change?

    state.rs:49
    if passed <= self.live_from {
    exityes: nothing to save, return

  3. for every record moment passed since the last change: at[e] = quantity

    state.rs:53
    for e in from..passed {

    between two changes the quantity is constant, so this is exactly what was held at each of those moments, however late it is written

  4. live_from = record moments passed

    state.rs:56
    self.live_from = passed;

  5. at_moment(e): the quantity at record moment e

    state.rs:61
    pub fn at_moment(&self, e: u16) -> u64 {
    e < live_from
    1. at[e]: saved by the first change after moment e

      state.rs:63
      self.at[e as usize]
    otherwise
    1. the quantity now: no change since moment e

      state.rs:65
      self.quantity

    a holder that never trades needs no transaction at all

  6. return

rule

Coupon and principal

Amounts in the cash token's smallest unit, on the holder's whole position, in 128-bit integers, rounded down once (SCoRE corporate actions standard 4).

  1. coupon(position, period)

    state.rs:189
    pub fn coupon(&self, position: u64, period: u16) -> Result<u64> {

  2. by the bond's coupon basis

    state.rs:190
    let amount = match self.basis {
    fixed per period
    1. position x face x rate / periods a year

      state.rs:191
      CouponBasis::FixedPerPeriod { periods_per_year } => fixed_coupon_amount(position, self.face_units, self.rate_bps, periods_per_year),
    30/360
    1. position x face x rate x days(30/360) / 360, on the period's unadjusted dates

      state.rs:193
      coupon_amount(position, self.face_units, self.rate_bps, self.accrual_basis()?, start, end) }

  3. the amount fits in a token amount

    state.rs:195
    amount.ok_or_else(|| error!(BondError::Overflow))
    failsOverflow

  4. fixed per period, in bond_math: gross = position x face (cash units) x rate (basis points)

    amount.rs:95
    let gross = u128::from(position).checked_mul(u128::from(face_units))?.checked_mul(u128::from(rate_bps))?;

  5. coupon = gross / (10,000 x periods a year), rounded down

    amount.rs:96
    u64::try_from(gross / (BPS_PER_UNIT * u128::from(periods_per_year))).ok()

    sample: 10 bonds x 1,000 x 10% / 2 = 500

  6. principal(position) = position x face x redemption price / 10,000, rounded down

    state.rs:199
    principal_amount(position, self.face_units, self.redemption_price_bps).ok_or_else(|| error!(BondError::Overflow))
    failsOverflow

  7. return

rule

check_serviceable

Called by place. Every amount the bond can owe fits in the cash token's amounts, so no budget can fail later.

  1. check_serviceable(new supply)

    state.rs:204
    pub fn check_serviceable(&self, supply: u64) -> Result<()> {

  2. by the bond's coupon basis

    state.rs:206
    match basis {
    fixed per period
    1. one coupon on the whole supply fits

      state.rs:208
      fixed_coupon_amount(supply, self.face_units, self.rate_bps, periods_per_year).ok_or(BondError::IssueTooLarge)?;
      failsIssueTooLarge
    30/360
    1. each period's coupon on the whole supply fits

      state.rs:212
      coupon_amount(supply, self.face_units, self.rate_bps, basis, start, end).ok_or(BondError::IssueTooLarge)?;
      failsIssueTooLarge

  3. the principal on the whole supply fits

    state.rs:215
    principal_amount(supply, self.face_units, self.redemption_price_bps).ok_or(BondError::IssueTooLarge)?;
    failsIssueTooLarge

    checked when the supply grows; it only shrinks afterwards

  4. return

rule

Offer.accept_next

The tender rule. Tenders are taken in the order they were made; an oversubscribed offer gives each the pro-rata share of the running total minus what the earlier ones got.

  1. accept_next(tendered)

    state.rs:295
    pub fn accept_next(&mut self, quantity: u64) -> Option<u64> {

  2. before = bonds tendered by the tenders resolved so far

    state.rs:296
    let before = self.resolved_quantity;

  3. after = before + this tender

    state.rs:297
    let after = before.checked_add(quantity)?;

  4. is the offer oversubscribed?

    state.rs:298
    let accepted = if self.total <= self.max_quantity {
    total at most the maximum
    1. all of this tender is accepted

      state.rs:299
      quantity
    total above the maximum
    1. share(q) = q x maximum / total, rounded down

      state.rs:301
      let share = |q: u64| (u128::from(q) * u128::from(self.max_quantity) / u128::from(self.total)) as u64;
    2. accepted = share(after) - share(before)

      state.rs:302
      share(after) - share(before)

    the shares telescope: over all tenders they add up to share(total) = the maximum, so the offer buys exactly its maximum; each tender gets its own share rounded down, or one bond more

  5. resolved_quantity = after; one more tender resolved

    state.rs:304
    self.resolved_quantity = after;

  6. return the accepted part

Moving value

function

pay_cash

Pays from a vault: coupons, principal, buyback and tender payments.

  1. pay_cash(vault, holder's cash account, amount)

    settlement.rs:47
    pub fn pay_cash(c: &Cash, amount: u64, decimals: u8, memo: &str, seeds: &[&[u8]]) -> Result<()> {

  2. requires_memo: an SPL Token account never requires a memo

    settlement.rs:38
    if *account.owner != spl_token_2022::ID {

  3. a Token-2022 cash account is read for its memo setting

    settlement.rs:42
    let state = StateWithExtensions::<TokenAccountState>::unpack(&data).map_err(|_| error!(BondError::UnsupportedCashToken))?;
    failsUnsupportedCashToken

  4. does the receiving account require a memo?

    settlement.rs:48
    if requires_memo(c.holder_cash)? {
    yes
    1. the Memo program was passed

      settlement.rs:49
      let memo_program = c.memo_program.ok_or(BondError::MemoProgramMissing)?;
      failsMemoProgramMissing
    2. it is the real SPL Memo program

      settlement.rs:50
      require_keys_eq!(memo_program.key(), MEMO_PROGRAM_ID, BondError::MemoProgramMissing);
      failsMemoProgramMissing
    3. Memo: "bond_servicing: coupon e" (or redemption, offer)

      settlement.rs:52
      invoke(&ix, &[memo_program.clone()])?;
    no

      nothing else

  5. cash token TransferChecked: vault to the holder's cash account, amount and decimals checked; authority = Bond account, signed with its seeds

    settlement.rs:54
    token_interface::transfer_checked(
        CpiContext::new_with_signer(
            c.cash_token_program.key(),
            TransferChecked {

    the vault's authority is the Bond account, which has no private key: only this program can move a vault's cash

  6. return

function

burn_bonds

Retires bonds the program is paying for: redemption, buyback and tender settlement.

  1. burn_bonds(holder's bond account, quantity)

    settlement.rs:71
    pub fn burn_bonds<'info>(

  2. Token-2022 instruction 46/2: permissioned BurnChecked, quantity, 0 decimals

    settlement.rs:79
    let mut data = vec![PERMISSIONED_BURN_EXTENSION, PERMISSIONED_BURN_BURN_CHECKED];

  3. burn from the holder's bond account; the Bond account signs as burn authority and as permanent delegate

    settlement.rs:92
    invoke_signed(&burn, &[holder_bonds.clone(), mint.clone(), bond.clone(), bond.clone(), token_program.clone()], &[seeds])?;

    the holder does not sign: the bonds are burned in the same transaction that pays for them

  4. return

function

retire_quantity

After a burn: saves the histories first, so every right earned at a past record moment stays payable after the bonds are gone.

  1. retire_quantity(holder, bond, passed, quantity)

    settlement.rs:97
    pub fn retire_quantity(holder: &mut HolderRecord, bond: &mut Bond, bond_key: Pubkey, passed: u16, quantity: u64) -> Result<()> {

  2. save the holder's history

    settlement.rs:99
    if let Some((from_event, to_event)) = holder.position.save(passed) {
    see Position history

  3. PositionsSaved (if anything was saved)

    settlement.rs:100
    emit!(PositionsSaved { bond: bond_key, owner: holder.owner, from_event, to_event, quantity: before });

  4. holder's quantity - quantity

    settlement.rs:102
    holder.position.quantity = before.checked_sub(quantity).ok_or(BondError::Overflow)?;
    failsOverflow

  5. save the supply history

    settlement.rs:103
    bond.supply.save(passed);
    see Position history

  6. supply - quantity

    settlement.rs:104
    bond.supply.quantity = bond.supply.quantity.checked_sub(quantity).ok_or(BondError::Overflow)?;
    failsOverflow

  7. return