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.
How a call runs
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.
a transaction calls bond_servicing with instruction data and a list of accounts
lib.rs:21#[program]
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
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
failsAccountNotSignerAccountOwnedByWrongProgramConstraintHasOneConstraintSeedsa wrong account (another bond's record, another event's vault, a missing signature) stops here
the handler runs the program's checks and changes, top to bottom
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
Ok: every change commits together. An error anywhere: the runtime undoes every change of the transaction, the other programs' token moves included
Issue
create_bond
sent by: RegistrarCreates 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.
create_bond(terms) is called
lib.rs:25pub fn create_bond(ctx: Context<CreateBond>, terms: BondTerms) -> Result<()> {- Accounts: Anchor checks these first
pub struct CreateBond<'info>create_bond.rs:59 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe registrar signed (it pays the new accounts' rent)
create_bond.rs:61pub registrar: Signer<'info>,
failsAccountNotSignerthe new bond token's keypair signed: a fresh account
create_bond.rs:65pub mint: Signer<'info>,
failsAccountNotSignercreates 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 useone bond per token, at an address anyone can compute
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 useToken-2022 reads this list on every transfer to know which accounts to pass to the hook
- Handler
pub fn handle_create_bondcreate_bond.rs:92 - fails
InvalidTermsInvalidSchedule check the cash token
create_bond.rs:94check_cash_mint(&ctx.accounts.cash_mint.to_account_info())?;
see check_cash_mintfailsUnsupportedCashTokenwrite 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:96write_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
store the registrar, the two tokens, the terms and the time table
create_bond.rs:99bond.registrar = ctx.accounts.registrar.key();
chain origin = the chain clock now: bond time starts at bond_origin from this moment
create_bond.rs:114bond.chain_origin = Clock::get()?.unix_timestamp;
supply 0; issue size = max_supply from the terms
create_bond.rs:117bond.supply = PositionHistory::new(0);
BondCreated
create_bond.rs:121emit!(BondCreated {Ok: every change above commits together
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.
validate_terms(terms)
create_bond.rs:193fn validate_terms(t: &BondTerms) -> Result<()> {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:195require!( 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)failsInvalidTermsthe coupon basis is consistent
create_bond.rs:207match t.basis {fixed per periodperiods a year x months a period = 12
create_bond.rs:209periods_per_year > 0 && periods_per_year * t.months_per_period == 12,
failsInvalidTerms
30/360convention 0, 1 or 2 (Bond Basis, European, USA)
create_bond.rs:212CouponBasis::Thirty360 { convention } => require!(convention <= 2, BondError::InvalidTerms),failsInvalidTerms
recompute the regular schedule backward from maturity, unadjusted
create_bond.rs:218let no_holidays = Calendar::new(&[], &[]).map_err(|_| error!(BondError::InvalidTerms))?;
failsInvalidTermsInvalidSchedulea schedule whose first period is not a whole period (a stub) is refused
one payment day and one record day per period, at most 40 periods
create_bond.rs:222periods.len() == t.payment_days.len() && periods.len() == t.record_days.len() && periods.len() <= MAX_PERIODS,
failsInvalidSchedulebusiness day = not one of the bond's weekend days
create_bond.rs:225let business_day = |day: i32| t.weekend_days & (1 << Date::from_days(i64::from(day)).weekday() as u8) == 0;
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:227for ((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_recordfailsInvalidSchedulea holder list cannot be fixed after the money is due, and every period has exactly one record moment
return: the terms are accepted
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.
check_cash_mint(cash token)
create_bond.rs:175fn check_cash_mint(info: &AccountInfo) -> Result<()> {a classic SPL Token mint?
create_bond.rs:176if *info.owner == anchor_spl::token::ID {exityes: accepted (SPL Token has no extensions)otherwise it is a Token-2022 mint
create_bond.rs:179require_keys_eq!(*info.owner, spl_token_2022::ID, BondError::UnsupportedCashToken);
failsUnsupportedCashTokenits extensions can be read
create_bond.rs:181let mint = StateWithExtensions::<MintState>::unpack(&data).map_err(|_| error!(BondError::UnsupportedCashToken))?;
failsUnsupportedCashTokenno transfer fee, transfer hook, non-transferability or permanent delegate
create_bond.rs:183let refused = [ ExtensionType::TransferFeeConfig, ExtensionType::TransferHook, ExtensionType::NonTransferable, ExtensionType::PermanentDelegate, ];failsUnsupportedCashTokena 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
return: the cash token is accepted
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.
create_bond_mint
create_bond.rs:132fn create_bond_mint(ctx: &Context<CreateBond>) -> Result<()> {size of a token with a permanent delegate, a transfer hook and permissioned burn
create_bond.rs:137let space = ExtensionType::try_calculate_account_len::<MintState>(&[ ExtensionType::PermanentDelegate, ExtensionType::TransferHook, ])failsInvalidTermsSystem program: create the token account, owned by Token-2022; the registrar pays the rent
create_bond.rs:143system_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), … )?;Token-2022: permanent delegate = Bond account
create_bond.rs:156spl_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
Token-2022: transfer hook = bond_servicing
create_bond.rs:157transfer_hook::instruction::initialize(&spl_token_2022::ID, &mint_key, Some(bond_key), Some(crate::ID))?,
every transfer of the bond calls this program
Token-2022: permissioned burn, authority = Bond account
create_bond.rs:161data: permissioned_burn,
holders cannot burn bonds themselves: every burn is the program's, paired with a payment
Token-2022: the token itself, 0 decimals, mint authority = Bond account, no freeze authority
create_bond.rs:163spl_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
the four Token-2022 instructions run in this order, the token itself last
create_bond.rs:165for ix in instructions.iter() {return
register_holder
sent by: RegistrarAdmits 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.
register_holder(wallet) is called
lib.rs:29pub fn register_holder(ctx: Context<RegisterHolder>, owner: Pubkey) -> Result<()> {- Accounts: Anchor checks these first
pub struct RegisterHolder<'info>register_holder.rs:12 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe registrar signed
register_holder.rs:14pub registrar: Signer<'info>,
failsAccountNotSignerthe signer is this bond's registrar
register_holder.rs:16#[account(has_one = registrar)] pub bond: Box<Account<'info, Bond>>,
failsConstraintHasOneonly the registrar admits holders
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 useone record per wallet and bond, created once
- Handler
pub fn handle_register_holderregister_holder.rs:31 record moments already passed
register_holder.rs:33let passed = bond.passed_record_moments(bond.now()?)?;
see Bond time and record momentsposition: 0 at every record moment so far; live_from = record moments passed
register_holder.rs:38holder.position = PositionHistory::new(passed);
a holder registered late held nothing at earlier record moments
no coupon paid, nothing locked, nothing redeemed
register_holder.rs:39holder.paid = 0;
HolderRegistered
register_holder.rs:43emit!(HolderRegistered { bond: bond.key(), owner });Ok: every change above commits together
place
sent by: RegistrarIssues bonds to a registered holder, before the first record moment and up to the issue size.
place(amount) is called
lib.rs:33pub fn place(ctx: Context<Place>, amount: u64) -> Result<()> {- Accounts: Anchor checks these first
pub struct Place<'info>place.rs:14 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe registrar signed
place.rs:15pub registrar: Signer<'info>,
failsAccountNotSignerthis 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>>,
failsConstraintHasOnethe 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>>,
failsConstraintHasOneConstraintSeedsthe 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>>,failsConstraintAssociatedConstraintTokenOwnerthe real Token-2022 program
place.rs:34pub token_program: Program<'info, Token2022>,
failsInvalidProgramId- Handler
pub fn handle_placeplace.rs:37 bond time is before the first record moment
place.rs:39require!(bond.now()? < bond.record_time(0)?, BondError::PlacementClosed);
failsPlacementClosedthe issue is complete before any coupon's holder list is fixed, so no position history needs saving here
new supply = supply + amount
place.rs:40let new_supply = bond.supply.quantity.checked_add(amount).ok_or(BondError::IssueTooLarge)?;
failsIssueTooLargenew supply is at most the issue size
place.rs:41require!(new_supply <= bond.max_supply, BondError::ExceedsIssueSize);
failsExceedsIssueSizethe registrar cannot issue more bonds than the terms allow
every coupon and the principal on the new supply fit in a token amount
place.rs:42bond.check_serviceable(new_supply)?;
see check_serviceablefailsIssueTooLargeToken-2022 MintTo: amount bonds into the holder's account; the Bond account signs as mint authority
place.rs:45token_2022::mint_to( CpiContext::new_with_signer( ctx.accounts.token_program.key(), MintTo {holder's quantity + amount
place.rs:60holder.position.quantity = holder.position.quantity.checked_add(amount).ok_or(BondError::Overflow)?;
failsOverflowsupply + amount
place.rs:62bond.supply.quantity = bond.supply.quantity.checked_add(amount).ok_or(BondError::Overflow)?;
failsOverflowthe supply is counted by the program, so a coupon's budget never needs a count of holders
BondsPlaced
place.rs:63emit!(BondsPlaced { bond: bond.key(), owner: holder.owner, amount });Ok: every change above commits together
Every transfer
transfer_hook
sent by: Token-2022, inside a holder's transferToken-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.
Token-2022 calls transfer_hook(amount)
lib.rs:38pub fn transfer_hook(ctx: Context<TransferHook>, amount: u64) -> Result<()> {- Accounts: passed by Token-2022 from the hook's list
pub struct TransferHook<'info>transfer_hook.rs:25 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe source is an account of the bond token
transfer_hook.rs:26#[account(token::mint = mint)] pub source_token: InterfaceAccount<'info, TokenAccount>,
failsConstraintTokenMintthe destination is an account of the bond token
transfer_hook.rs:31#[account(token::mint = mint)] pub destination_token: InterfaceAccount<'info, TokenAccount>,
failsConstraintTokenMintthe 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>,
failsConstraintSeedsthe 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>>,
failsConstraintSeedsthe 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>,
failsConstraintSeedsthe 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- Handler
pub fn handle_transfer_hooktransfer_hook.rs:54 Token-2022 marked the source account as being transferred
transfer_hook.rs:56check_is_transferring(&a.source_token)?;
failsNotTransferringthe hook runs only inside a real transfer: calling it directly to fake a change of position fails
the source is its owner's associated account
transfer_hook.rs:57check_associated(&a.source_token, &a.mint)?;
failsNotAssociatedTokenAccountone bond account per holder, so one holder record is one position
the destination is its owner's associated account
transfer_hook.rs:58check_associated(&a.destination_token, &a.mint)?;
failsNotAssociatedTokenAccountthe sender's holder record exists (bond_servicing owns it)
transfer_hook.rs:59let mut source = read_record(&a.source_holder.to_account_info())?;
failsAccountOwnedByWrongPrograman unregistered wallet has no record: it can neither send nor receive the bond
the receiver's holder record exists, unless the sender sends to itself
transfer_hook.rs:61let mut destination = if same { None } else { Some(read_record(&a.destination_holder.to_account_info())?) };failsAccountOwnedByWrongProgrambond time is before the final record moment
transfer_hook.rs:64require!(now < bond.final_record_time()?, BondError::TransfersClosedAtMaturity);
failsTransfersClosedAtMaturityredemption pays on the holdings at the final record moment, so they cannot move after it
the same account on both sides?
transfer_hook.rs:65if same { return Ok(()); }exityes: nothing changes, return Okrecord moments passed (searched from the later of the two holders' last changes)
transfer_hook.rs:67let passed = bond.passed_record_moments_from(now, source.position.live_from.max(destination.position.live_from))?;
see Bond time and record momentsbalances before the transfer: sender's now + amount, receiver's now - amount
transfer_hook.rs:68let source_before = a.source_token.amount.checked_add(amount).ok_or(BondError::Overflow)?;
failsOverflowsender: check, save the history, set the new quantity
transfer_hook.rs:70update_record(&mut source, bond.key(), passed, source_before, a.source_token.amount)?;
see update_recordfailsPositionMismatchthe sender keeps at least its tendered bonds
transfer_hook.rs:71require!(source.locked <= a.source_token.amount, BondError::BondsLocked);
failsBondsLockedtendered bonds stay in the account and keep their coupons, but cannot leave it
receiver: check, save the history, set the new quantity
transfer_hook.rs:72update_record(destination, bond.key(), passed, destination_before, a.destination_token.amount)?;
see update_recordfailsPositionMismatchwrite both holder records
transfer_hook.rs:73source.try_serialize(&mut &mut a.source_holder.try_borrow_mut_data()?[..])?;
Ok: the transfer completes with both records updated; any error undoes the transfer itself
update_record
Called by transfer_hook for the sender and the receiver.
update_record(record, passed, before, after)
transfer_hook.rs:83fn update_record(record: &mut HolderRecord, bond: Pubkey, passed: u16, before: u64, after: u64) -> Result<()> {the record's quantity equals the account's balance before the transfer
transfer_hook.rs:84require!(record.position.quantity == before, BondError::PositionMismatch);
failsPositionMismatchevery 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
save the quantity for every record moment passed since the last change
transfer_hook.rs:85if let Some((from_event, to_event)) = record.position.save(passed) {see Position historyPositionsSaved: the holder held this quantity at those record moments
transfer_hook.rs:86emit!(PositionsSaved { bond, owner: record.owner, from_event, to_event, quantity: before });quantity = the balance after the transfer
transfer_hook.rs:88record.position.quantity = after;
return
Coupons and redemption
open_event
sent by: AnyoneOpens 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.
open_event(index) is called
lib.rs:42pub fn open_event(ctx: Context<OpenEvent>, index: u16) -> Result<()> {- Accounts: Anchor checks these first
pub struct OpenEvent<'info>open_event.rs:19 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdany wallet signed (it pays the rent)
open_event.rs:21pub payer: Signer<'info>,
failsAccountNotSignerthe bond's own cash token
open_event.rs:23#[account(has_one = cash_mint)] pub bond: Box<Account<'info, Bond>>,
failsConstraintHasOnecreates 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 useeach coupon and the redemption is opened once
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 useeach event has its own cash, so one coupon cannot spend another's
- Handler
pub fn handle_open_eventopen_event.rs:52 the index is a coupon (0 to n-1) or the redemption (n)
open_event.rs:54let period = bond.period_of(index)?;
see Bond time and record momentsfailsEventOutOfRangeits record moment
open_event.rs:55let record_time = bond.record_time(index)?;
see Bond time and record momentsthe record moment has passed
open_event.rs:56require!(bond.now()? >= record_time, BondError::RecordMomentNotReached);
failsRecordMomentNotReachedthe supply at the record moment is final only once that moment has passed
supply at the record moment, from the supply history
open_event.rs:59let supply = bond.supply.at_moment(period);
see Position historyno holder is counted
the budget on the whole supply
open_event.rs:60let budget = if is_redemption { bond.principal(supply)? } else { bond.coupon(supply, period)? };redemptionprincipal(supply)
open_event.rs:60let budget = if is_redemption { bond.principal(supply)? } else { bond.coupon(supply, period)? };see Coupon and principal
couponcoupon(supply, period)
open_event.rs:60let 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
store the kind, record moment, payment time, supply at the record moment, budget and vault; funded = false
open_event.rs:66event.record_time = record_time;
EventOpened
open_event.rs:73emit!(EventOpened { bond: bond.key(), event: index, supply_at_record: supply, budget, vault: event.vault });Ok: every change above commits together
confirm_funding
sent by: AnyoneAfter 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.
confirm_funding is called
lib.rs:46pub fn confirm_funding(ctx: Context<ConfirmFunding>) -> Result<()> {- Accounts: Anchor checks these first
pub struct ConfirmFunding<'info>confirm_funding.rs:14 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe 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- Handler
pub fn handle_confirm_fundingconfirm_funding.rs:21 not confirmed before
confirm_funding.rs:23require!(!event.funded, BondError::AlreadyFunded);
failsAlreadyFundedread the vault's balance
confirm_funding.rs:24let vault_balance = ctx.accounts.vault.amount;
the vault holds at least the budget
confirm_funding.rs:25require!(vault_balance >= event.budget, BondError::InsufficientFunding);
failsInsufficientFundingpayment starts only when every holder can be paid
funded = true
confirm_funding.rs:26event.funded = true;
EventFunded
confirm_funding.rs:27emit!(EventFunded { bond: event.bond, event: event.index, vault_balance, budget: event.budget });Ok: every change above commits together
pay_coupon
sent by: AnyonePays 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.
pay_coupon is called
lib.rs:50pub fn pay_coupon(ctx: Context<PayCoupon>) -> Result<()> {- Accounts: Anchor checks these first
pub struct PayCoupon<'info>pay_coupon.rs:19 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdany wallet signed (it pays the fee)
pay_coupon.rs:21pub payer: Signer<'info>,
failsAccountNotSignerthe bond's own cash token
pay_coupon.rs:23#[account(has_one = cash_mint)] pub bond: Box<Account<'info, Bond>>,
failsConstraintHasOnean 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>>,
failsConstraintHasOneConstraintSeedsanother event's vault is refused
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>>,
failsConstraintHasOneConstraintSeedsthe 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>>,failsConstraintAssociatedConstraintTokenOwnerwhoever sends the transaction, the coupon can only go to the holder
- Handler
pub fn handle_pay_couponpay_coupon.rs:57 a coupon, not the redemption
pay_coupon.rs:60require!(event.kind == EventKind::Coupon, BondError::WrongEventKind);
failsWrongEventKindthe event is confirmed funded
pay_coupon.rs:61require!(event.funded, BondError::NotFunded);
failsNotFundedbond time has reached the payment time
pay_coupon.rs:62require!(bond.now()? >= event.payment_time, BondError::PaymentNotOpen);
failsPaymentNotOpencoupon e not paid to this holder yet
pay_coupon.rs:64require!(!ctx.accounts.holder.is_paid(e), BondError::AlreadyPaid);
failsAlreadyPaidthe holder's position at record moment e
pay_coupon.rs:65let position = ctx.accounts.holder.position.at_moment(e);
see Position historywhat it held at the record moment, whatever it holds now
the coupon on that position
pay_coupon.rs:66let amount = bond.coupon(position, e)?;
see Coupon and principalsomething is owed
pay_coupon.rs:67require!(amount > 0, BondError::NothingToPay);
failsNothingToPaypay from the event's vault to the holder's cash account
pay_coupon.rs:72pay_cash( &Cash {see pay_cashfailsMemoProgramMissingmark coupon e paid
pay_coupon.rs:88holder.paid |= 1u64 << e;
in the same transaction as the payment: both happen or neither
CouponPaid
pay_coupon.rs:89emit!(CouponPaid { bond: ctx.accounts.bond.key(), event: e, owner: holder.owner, position, amount });Ok: every change above commits together
redeem_holder
sent by: AnyoneAt 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.
redeem_holder is called
lib.rs:54pub fn redeem_holder(ctx: Context<RedeemHolder>) -> Result<()> {- Accounts: Anchor checks these first
pub struct RedeemHolder<'info>redeem_holder.rs:23 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdany wallet signed (it pays the fee)
redeem_holder.rs:25pub payer: Signer<'info>,
failsAccountNotSignerthe bond's own tokens
redeem_holder.rs:27#[account(mut, has_one = cash_mint, has_one = mint)] pub bond: Box<Account<'info, Bond>>,
failsConstraintHasOnean 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>>,
failsConstraintHasOneConstraintSeedsthe 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>>,
failsConstraintHasOneConstraintSeedsthe 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>>,failsConstraintAssociatedConstraintTokenOwnerthe 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>>,failsConstraintAssociatedConstraintTokenOwnerthe real Token-2022 program
redeem_holder.rs:67pub token_program: Program<'info, Token2022>,
failsInvalidProgramId- Handler
pub fn handle_redeem_holderredeem_holder.rs:73 the redemption event
redeem_holder.rs:75require!(a.event.kind == EventKind::Redemption, BondError::WrongEventKind);
failsWrongEventKindthe redemption is confirmed funded
redeem_holder.rs:76require!(a.event.funded, BondError::NotFunded);
failsNotFundedbond time has reached the payment time
redeem_holder.rs:77require!(a.bond.now()? >= a.event.payment_time, BondError::PaymentNotOpen);
failsPaymentNotOpenevery record moment has passed
redeem_holder.rs:78let passed = a.bond.periods();
the record's quantity equals the bond balance
redeem_holder.rs:79let position = a.holder.position.quantity;
failsPositionMismatchfree = quantity - bonds accepted by a tender and not yet bought
redeem_holder.rs:81let free = position.checked_sub(a.holder.locked).ok_or(BondError::Overflow)?;
failsOverflowthose are left to their offer, which pays for them
something to redeem
redeem_holder.rs:82require!(free > 0, BondError::NothingToPay);
failsNothingToPayredeemed so far; after = before + free
redeem_holder.rs:83let before = a.holder.redeemed;
failsOverflowprincipal = principal(after) - principal(before)
redeem_holder.rs:85let principal = a.bond.principal(after)? - a.bond.principal(before)?;
see Coupon and principalseveral partial redemptions round exactly like one
burn the free bonds
redeem_holder.rs:92burn_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- fails
MemoProgramMissing save both histories, then take the bonds off the holder and the supply
redeem_holder.rs:115retire_quantity(&mut a.holder, &mut a.bond, bond_key, passed, free)?;
see retire_quantityfailsOverflowthe final coupon stays payable whether it is paid before or after
redeemed = after
redeem_holder.rs:116a.holder.redeemed = after;
BondsRedeemed
redeem_holder.rs:117emit!(BondsRedeemed { bond: bond_key, owner: a.holder.owner, burned: free, principal });Ok: every change above commits together
Buyback and tender
create_offer
sent by: RegistrarThe 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).
create_offer(id, kind, price, maximum, window) is called
lib.rs:58pub fn create_offer(
- Accounts: Anchor checks these first
pub struct CreateOffer<'info>offer.rs:29 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe registrar signed
offer.rs:31pub registrar: Signer<'info>,
failsAccountNotSignerthis bond's registrar and cash token
offer.rs:33#[account(has_one = registrar, has_one = cash_mint)] pub bond: Box<Account<'info, Bond>>,
failsConstraintHasOnecreates 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 usecreates 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- Handler
pub fn handle_create_offeroffer.rs:62 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:72require!( 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 );failsInvalidOfferan offer must end while bonds can still move
buyback (first come) or tender offer (pro rata)
offer.rs:83offer.kind = if pro_rata { OfferKind::ProRata } else { OfferKind::FirstCome };nothing bought or tendered, not funded, no tenders
offer.rs:88offer.total = 0;
OfferCreated
offer.rs:95emit!(OfferCreated { bond: bond.key(), offer: id, pro_rata, price_units, max_quantity, open_time, close_time });Ok: every change above commits together
confirm_offer_funding
sent by: AnyoneAnyone confirms that the offer's vault holds price x maximum. Only then may holders sell or tender.
confirm_offer_funding is called
lib.rs:70pub fn confirm_offer_funding(ctx: Context<ConfirmOfferFunding>) -> Result<()> {- Accounts: Anchor checks these first
pub struct ConfirmOfferFunding<'info>offer.rs:100 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe 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- Handler
pub fn handle_confirm_offer_fundingoffer.rs:107 not confirmed before
offer.rs:109require!(!offer.funded, BondError::AlreadyFunded);
failsAlreadyFundedbudget = price x maximum
offer.rs:110let budget = offer.budget().ok_or(BondError::Overflow)?;
failsOverflowthe vault holds at least the budget
offer.rs:112require!(vault_balance >= budget, BondError::InsufficientFunding);
failsInsufficientFundingholders sell or tender only into an offer that can pay for all of it
funded = true
offer.rs:113offer.funded = true;
OfferFunded
offer.rs:114emit!(OfferFunded { bond: offer.bond, offer: offer.id, vault_balance, budget });Ok: every change above commits together
Retirement accounts
The accounts sell_to_offer and settle_tender share: whose bonds are burned and where the cash goes.
- Accounts: Anchor checks these first, in this order
pub struct Retirement<'info>offer.rs:123 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIda wallet signed (it pays the fee)
offer.rs:125pub payer: Signer<'info>,
failsAccountNotSignerthe bond's own tokens
offer.rs:127#[account(mut, has_one = mint, has_one = cash_mint)] pub bond: Box<Account<'info, Bond>>,
failsConstraintHasOnethe 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>>,
failsConstraintHasOneConstraintSeedsthe 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>>,failsConstraintAssociatedConstraintTokenOwnerthe 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>>,failsConstraintAssociatedConstraintTokenOwnerthe cash can only go to the holder whose bonds are burned
the real Token-2022 program
offer.rs:164pub token_program: Program<'info, Token2022>,
failsInvalidProgramIdthe instruction's own accounts follow
sell_to_offer
sent by: HolderA holder sells bonds into a buyback: burned and paid for in one transaction, first come, first served until the maximum.
sell_to_offer(quantity) is called
lib.rs:74pub fn sell_to_offer(ctx: Context<SellToOffer>, quantity: u64) -> Result<()> {- Accounts: Anchor checks these first
pub struct SellToOffer<'info>offer.rs:212 the signer is the holder whose bonds are sold
offer.rs:215#[account(constraint = seller.key() == retirement.owner.key())] pub seller: Signer<'info>,
failsAccountNotSignerConstraintRawan 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- Handler
pub fn handle_sell_to_offeroffer.rs:227 a buyback (first come)
offer.rs:230require!(a.offer.kind == OfferKind::FirstCome, BondError::WrongOfferKind);
failsWrongOfferKindthe offer is confirmed funded
offer.rs:231require!(a.offer.funded, BondError::OfferNotFunded);
failsOfferNotFundedinside the window
offer.rs:232require!(a.offer.open_time <= now && now < a.offer.close_time, BondError::OfferNotOpen);
failsOfferNotOpentotal = bought so far + quantity
offer.rs:233let total = a.offer.total.checked_add(quantity).ok_or(BondError::Overflow)?;
failsOverflowquantity above zero and the total at most the maximum
offer.rs:234require!(quantity > 0 && total <= a.offer.max_quantity, BondError::ExceedsOffer);
failsExceedsOfferat most the holder's untendered bonds
offer.rs:235require!(quantity <= a.retirement.holder.unlocked(), BondError::InsufficientUnlocked);
failsInsufficientUnlockedoffer total = total
offer.rs:236a.offer.total = total;
burn the bonds and pay price x quantity
offer.rs:237buy_back(&mut a.retirement, a.offer.id, a.offer.price_units, quantity)?;
see buy_backfailsPositionMismatchOverflowOk: every change above commits together
buy_back
Called by sell_to_offer and settle_tender.
buy_back(quantity, price)
offer.rs:172fn buy_back(r: &mut Retirement, offer_id: u16, price_units: u64, quantity: u64) -> Result<u64> {the record's quantity equals the bond balance
offer.rs:173require!(r.holder_bonds.amount == r.holder.position.quantity, BondError::PositionMismatch);
failsPositionMismatchpaid = quantity x price
offer.rs:174let paid = quantity.checked_mul(price_units).ok_or(BondError::Overflow)?;
failsOverflowrecord moments passed
offer.rs:175let passed = r.bond.passed_record_moments(r.bond.now()?)?;
see Bond time and record momentsburn the quantity
offer.rs:181burn_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- fails
MemoProgramMissing save both histories, then take the bonds off the holder and the supply
offer.rs:204retire_quantity(&mut r.holder, &mut r.bond, bond_key, passed, quantity)?;
see retire_quantityfailsOverflowa coupon whose record moment has passed is still paid on the bonds sold after it
BondsBoughtBack
offer.rs:205emit!(BondsBoughtBack { bond: bond_key, offer: offer_id, owner: r.holder.owner, burned: quantity, paid });return
change_tender
sent by: HolderA 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.
change_tender(quantity, withdraw) is called
lib.rs:78pub fn change_tender(ctx: Context<ChangeTender>, quantity: u64, withdraw: bool) -> Result<()> {- Accounts: Anchor checks these first
pub struct ChangeTender<'info>offer.rs:244 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdthe holder signed
offer.rs:246pub owner: Signer<'info>,
failsAccountNotSigneran 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>>,
failsConstraintHasOneConstraintSeedsthe 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>>,
failsConstraintHasOneConstraintSeedsits 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- Handler
pub fn handle_change_tenderoffer.rs:270 a tender offer (pro rata)
offer.rs:273require!(a.offer.kind == OfferKind::ProRata, BondError::WrongOfferKind);
failsWrongOfferKindthe offer is confirmed funded
offer.rs:274require!(a.offer.funded, BondError::OfferNotFunded);
failsOfferNotFundedinside the window
offer.rs:275require!(a.offer.open_time <= now && now < a.offer.close_time, BondError::OfferNotOpen);
failsOfferNotOpenquantity above zero
offer.rs:276require!(quantity > 0, BondError::ExceedsOffer);
failsExceedsOfferthe holder's first tender in this offer?
offer.rs:278if tender.offer == Pubkey::default() {firstits number = tenders made so far; the offer counts one more
offer.rs:281tender.number = a.offer.tenders;
failsOverflow
laternothing else
the number fixes the order in which tenders are resolved
withdraw or tender more?
offer.rs:289if withdraw {withdrawat most what it tendered
offer.rs:290require!(quantity <= tender.quantity, BondError::ExceedsOffer);
failsExceedsOffertender, lock and offer total - quantity
offer.rs:291tender.quantity -= quantity;
tenderat most its untendered bonds
offer.rs:295require!(quantity <= a.holder.unlocked(), BondError::InsufficientUnlocked);
failsInsufficientUnlockedtender, lock and offer total + quantity
offer.rs:296tender.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
TenderChanged
offer.rs:300emit!(TenderChanged {Ok: every change above commits together
resolve_tender
sent by: AnyoneAfter 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.
resolve_tender is called
lib.rs:82pub fn resolve_tender(ctx: Context<ResolveTender>) -> Result<()> {- Accounts: Anchor checks these first
pub struct ResolveTender<'info>offer.rs:328 every typed account belongs to the expected program and has the expected type
failsAccountOwnedByWrongProgramAccountDiscriminatorMismatchInvalidProgramIdan 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>>,
failsConstraintHasOneConstraintSeedsa 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>>,
failsConstraintHasOneConstraintSeedsthat 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- Handler
pub fn handle_resolve_tenderoffer.rs:343 a tender offer (pro rata)
offer.rs:345require!(a.offer.kind == OfferKind::ProRata, BondError::WrongOfferKind);
failsWrongOfferKindthe window has closed
offer.rs:346require!(a.bond.now()? >= a.offer.close_time, BondError::OfferStillOpen);
failsOfferStillOpenresolve this tender
offer.rs:347resolve(a.bond.key(), &mut a.offer, &mut a.tender, &mut a.holder)
see resolvefailsTenderOutOfOrderOverflowOk: every change above commits together
resolve
Called by resolve_tender, and by settle_tender when nobody has resolved the tender yet.
resolve(offer, tender, holder)
offer.rs:313fn resolve(bond_key: Pubkey, offer: &mut Offer, tender: &mut Tender, holder: &mut HolderRecord) -> Result<()> {already resolved?
offer.rs:314if tender.resolved {exityes: left as it is, returnthis tender's number is the next to resolve
offer.rs:317require!(tender.number == offer.resolved, BondError::TenderOutOfOrder);
failsTenderOutOfOrdertenders are resolved in the order they were made, so nobody gains by choosing when to resolve
the accepted part
offer.rs:318let accepted = offer.accept_next(tender.quantity).ok_or(BondError::Overflow)?;
see Offer.accept_nextfailsOverflowrejected = tendered - accepted: unlocked at once
offer.rs:320holder.locked = holder.locked.checked_sub(rejected).ok_or(BondError::Overflow)?;
failsOverflowaccepted is fixed; resolved = true
offer.rs:321tender.accepted = accepted;
TenderResolved
offer.rs:323emit!(TenderResolved { bond: bond_key, offer: offer.id, owner: holder.owner, accepted, rejected });return
settle_tender
sent by: AnyoneAfter the window, buys one tender's accepted part: burned and paid for in one transaction, and released from the lock.
settle_tender is called
lib.rs:86pub fn settle_tender(ctx: Context<SettleTender>) -> Result<()> {- Accounts: Anchor checks these first
pub struct SettleTender<'info>offer.rs:351 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>>,failsConstraintRawConstraintSeedsthe 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- Handler
pub fn handle_settle_tenderoffer.rs:373 a tender offer (pro rata)
offer.rs:375require!(a.offer.kind == OfferKind::ProRata, BondError::WrongOfferKind);
failsWrongOfferKindthe window has closed
offer.rs:376require!(a.retirement.bond.now()? >= a.offer.close_time, BondError::OfferStillOpen);
failsOfferStillOpenresolve it first if nobody has
offer.rs:377resolve(a.retirement.bond.key(), &mut a.offer, &mut a.tender, &mut a.retirement.holder)?;
see resolvefailsTenderOutOfOrderOverflownot settled before
offer.rs:378require!(!a.tender.settled, BondError::AlreadySettled);
failsAlreadySettledrelease the accepted part from the lock; settled = true
offer.rs:380a.retirement.holder.locked = a.retirement.holder.locked.checked_sub(accepted).ok_or(BondError::Overflow)?;
failsOverflowanything accepted?
offer.rs:382if accepted > 0 {exitno: done, return Okburn the accepted bonds and pay price x accepted
offer.rs:383buy_back(&mut a.retirement, a.offer.id, a.offer.price_units, accepted)?;
see buy_backfailsPositionMismatchOverflowOk: every change above commits together
Rules the instructions use
Bond time and record moments
Every date rule compares bond time, read from the chain clock. Nobody passes a time in.
period_of(event)
state.rs:119pub fn period_of(&self, event: u16) -> Result<u16> {the event exists: coupons 0 to n-1, the redemption n
state.rs:120require!(event <= self.redemption_event(), BondError::EventOutOfRange);
failsEventOutOfRangethe redemption uses the final coupon period's dates
state.rs:121Ok(if event == self.redemption_event() { event - 1 } else { event })now(): read the chain clock (Clock sysvar)
state.rs:125let chain_now = Clock::get()?.unix_timestamp;
the cluster's time; no instruction argument can change it
bond time = bond origin + (chain time - chain origin) x 86,400 / chain seconds per bond day
state.rs:126chain_to_bond_time(chain_now, self.chain_origin, self.bond_origin, self.chain_seconds_per_bond_day)
failsOverflow86,400 is real time; the devnet runs compress a bond day into a few chain seconds
a local moment = day x 86,400 + seconds after midnight - the bond's UTC offset
state.rs:131i64::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
record moment of an event = its period's record day at the record time
state.rs:137Ok(self.local_moment(self.record_days[period as usize], self.record_seconds_local))
payment opens on the payment day at the payment time
state.rs:143Ok(self.local_moment(self.payment_days[period as usize], self.payment_seconds_local))
final record moment = the last coupon's record moment
state.rs:148self.record_time(self.periods() - 1)
transfers stop here; offers must close by it
record moments passed = how many record moments are at or before now (a binary search starting from the holder's last change)
state.rs:164let passed = self.record_days[lo..].partition_point(|day| self.local_moment(*day, self.record_seconds_local) <= bond_now);
return
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.
save(passed): before every change
state.rs:48pub fn save(&mut self, passed: u16) -> Option<(u16, u16)> {no record moment passed since the last change?
state.rs:49if passed <= self.live_from {exityes: nothing to save, returnfor every record moment passed since the last change: at[e] = quantity
state.rs:53for 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
live_from = record moments passed
state.rs:56self.live_from = passed;
at_moment(e): the quantity at record moment e
state.rs:61pub fn at_moment(&self, e: u16) -> u64 {e < live_fromat[e]: saved by the first change after moment e
state.rs:63self.at[e as usize]
otherwisethe quantity now: no change since moment e
state.rs:65self.quantity
a holder that never trades needs no transaction at all
return
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).
coupon(position, period)
state.rs:189pub fn coupon(&self, position: u64, period: u16) -> Result<u64> {by the bond's coupon basis
state.rs:190let amount = match self.basis {fixed per periodposition x face x rate / periods a year
state.rs:191CouponBasis::FixedPerPeriod { periods_per_year } => fixed_coupon_amount(position, self.face_units, self.rate_bps, periods_per_year),
30/360position x face x rate x days(30/360) / 360, on the period's unadjusted dates
state.rs:193coupon_amount(position, self.face_units, self.rate_bps, self.accrual_basis()?, start, end) }
the amount fits in a token amount
state.rs:195amount.ok_or_else(|| error!(BondError::Overflow))
failsOverflowfixed per period, in bond_math: gross = position x face (cash units) x rate (basis points)
amount.rs:95let gross = u128::from(position).checked_mul(u128::from(face_units))?.checked_mul(u128::from(rate_bps))?;
coupon = gross / (10,000 x periods a year), rounded down
amount.rs:96u64::try_from(gross / (BPS_PER_UNIT * u128::from(periods_per_year))).ok()
sample: 10 bonds x 1,000 x 10% / 2 = 500
principal(position) = position x face x redemption price / 10,000, rounded down
state.rs:199principal_amount(position, self.face_units, self.redemption_price_bps).ok_or_else(|| error!(BondError::Overflow))
failsOverflowreturn
check_serviceable
Called by place. Every amount the bond can owe fits in the cash token's amounts, so no budget can fail later.
check_serviceable(new supply)
state.rs:204pub fn check_serviceable(&self, supply: u64) -> Result<()> {by the bond's coupon basis
state.rs:206match basis {fixed per periodone coupon on the whole supply fits
state.rs:208fixed_coupon_amount(supply, self.face_units, self.rate_bps, periods_per_year).ok_or(BondError::IssueTooLarge)?;
failsIssueTooLarge
30/360each period's coupon on the whole supply fits
state.rs:212coupon_amount(supply, self.face_units, self.rate_bps, basis, start, end).ok_or(BondError::IssueTooLarge)?;
failsIssueTooLarge
the principal on the whole supply fits
state.rs:215principal_amount(supply, self.face_units, self.redemption_price_bps).ok_or(BondError::IssueTooLarge)?;
failsIssueTooLargechecked when the supply grows; it only shrinks afterwards
return
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.
accept_next(tendered)
state.rs:295pub fn accept_next(&mut self, quantity: u64) -> Option<u64> {before = bonds tendered by the tenders resolved so far
state.rs:296let before = self.resolved_quantity;
after = before + this tender
state.rs:297let after = before.checked_add(quantity)?;
is the offer oversubscribed?
state.rs:298let accepted = if self.total <= self.max_quantity {total at most the maximumall of this tender is accepted
state.rs:299quantity
total above the maximumshare(q) = q x maximum / total, rounded down
state.rs:301let share = |q: u64| (u128::from(q) * u128::from(self.max_quantity) / u128::from(self.total)) as u64;
accepted = share(after) - share(before)
state.rs:302share(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
resolved_quantity = after; one more tender resolved
state.rs:304self.resolved_quantity = after;
return the accepted part
Moving value
pay_cash
Pays from a vault: coupons, principal, buyback and tender payments.
pay_cash(vault, holder's cash account, amount)
settlement.rs:47pub fn pay_cash(c: &Cash, amount: u64, decimals: u8, memo: &str, seeds: &[&[u8]]) -> Result<()> {requires_memo: an SPL Token account never requires a memo
settlement.rs:38if *account.owner != spl_token_2022::ID {a Token-2022 cash account is read for its memo setting
settlement.rs:42let state = StateWithExtensions::<TokenAccountState>::unpack(&data).map_err(|_| error!(BondError::UnsupportedCashToken))?;
failsUnsupportedCashTokendoes the receiving account require a memo?
settlement.rs:48if requires_memo(c.holder_cash)? {yesthe Memo program was passed
settlement.rs:49let memo_program = c.memo_program.ok_or(BondError::MemoProgramMissing)?;
failsMemoProgramMissingit is the real SPL Memo program
settlement.rs:50require_keys_eq!(memo_program.key(), MEMO_PROGRAM_ID, BondError::MemoProgramMissing);
failsMemoProgramMissingMemo: "bond_servicing: coupon e" (or redemption, offer)
settlement.rs:52invoke(&ix, &[memo_program.clone()])?;
nonothing else
cash token TransferChecked: vault to the holder's cash account, amount and decimals checked; authority = Bond account, signed with its seeds
settlement.rs:54token_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
return
burn_bonds
Retires bonds the program is paying for: redemption, buyback and tender settlement.
burn_bonds(holder's bond account, quantity)
settlement.rs:71pub fn burn_bonds<'info>(
Token-2022 instruction 46/2: permissioned BurnChecked, quantity, 0 decimals
settlement.rs:79let mut data = vec![PERMISSIONED_BURN_EXTENSION, PERMISSIONED_BURN_BURN_CHECKED];
burn from the holder's bond account; the Bond account signs as burn authority and as permanent delegate
settlement.rs:92invoke_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
return
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.
retire_quantity(holder, bond, passed, quantity)
settlement.rs:97pub fn retire_quantity(holder: &mut HolderRecord, bond: &mut Bond, bond_key: Pubkey, passed: u16, quantity: u64) -> Result<()> {save the holder's history
settlement.rs:99if let Some((from_event, to_event)) = holder.position.save(passed) {see Position historyPositionsSaved (if anything was saved)
settlement.rs:100emit!(PositionsSaved { bond: bond_key, owner: holder.owner, from_event, to_event, quantity: before });holder's quantity - quantity
settlement.rs:102holder.position.quantity = before.checked_sub(quantity).ok_or(BondError::Overflow)?;
failsOverflowsupply - quantity
settlement.rs:104bond.supply.quantity = bond.supply.quantity.checked_sub(quantity).ok_or(BondError::Overflow)?;
failsOverflowreturn