← Program logic

programs/bond_servicing/src/instructions/create_bond.rs

Source at revision e5ff2b3cb157, the source of the program deployed on devnet. SHA-256 of this file 8cae0a4db6298a0980489b7be4d60490532309984788a33bde3f9e62eeebf4eb

1//! Create the bond: the Token-2022 bond mint, the Bond account with its terms and time table, and the transfer
2//! hook's extra-account list (one transaction, so trading can never start without it).
3//!
4//! The mint is created here, so its configuration is guaranteed rather than checked:
5//!   - mint authority, permanent delegate and permissioned-burn authority = Bond PDA;
6//!   - transfer hook = this program.
7//! With the PermissionedBurn extension every burn needs the Bond PDA's signature, so holders cannot burn bonds
8//! outside the program. Every balance change is then a transfer (seen by the hook) or a program mint or burn,
9//! which is what lets positions at past record moments be kept exactly (state.rs, PositionHistory).
10
11use anchor_lang::prelude::*;
12use anchor_lang::solana_program::instruction::{AccountMeta, Instruction};
13use anchor_lang::solana_program::program::invoke;
14use anchor_lang::system_program::{self, CreateAccount};
15use anchor_spl::token_2022::spl_token_2022::extension::{
16    transfer_hook, BaseStateWithExtensions, ExtensionType, StateWithExtensions,
17};
18use anchor_spl::token_2022::spl_token_2022::{self, state::Mint as MintState};
19use anchor_spl::token_2022::Token2022;
20use anchor_spl::token_interface::Mint;
21use bond_math::calendar::{BusinessDayConvention, Calendar};
22use bond_math::date::Date;
23use bond_math::schedule::regular_schedule;
24use bond_math::time::SECONDS_PER_DAY;
25
26use crate::constants::*;
27use crate::error::BondError;
28use crate::events::BondCreated;
29use crate::instructions::hook_accounts::{extra_account_metas_size, write_extra_account_metas};
30use crate::state::{Bond, CouponBasis, PositionHistory};
31
32#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
33pub struct BondTerms {
34    pub face_units: u64,
35    pub rate_bps: u32,
36    pub basis: CouponBasis,
37    pub redemption_price_bps: u32,
38    pub issue_day: i32,
39    pub maturity_day: i32,
40    pub months_per_period: u32,
41    /// Business-day-adjusted payment day per period, from the bond's calendar (off-chain).
42    pub payment_days: Vec<i32>,
43    /// Record day per period (equal to the payment day in markets that fix holders on payment day).
44    pub record_days: Vec<i32>,
45    pub record_seconds_local: i32,
46    pub payment_seconds_local: i32,
47    pub utc_offset_seconds: i32,
48    /// Non-business weekdays as a bit mask: bit 0 = Monday ... bit 6 = Sunday.
49    pub weekend_days: u8,
50    /// Bond time that corresponds to the chain time of this instruction.
51    pub bond_origin: i64,
52    /// 86_400 = real time; smaller compresses the bond's life (devnet demo).
53    pub chain_seconds_per_bond_day: i64,
54    /// Issue size in bonds: the most the registrar may ever place.
55    pub max_supply: u64,
56}
57
58#[derive(Accounts)]
59pub struct CreateBond<'info> {
60    #[account(mut)]
61    pub registrar: Signer<'info>,
62
63    /// The new bond mint: a fresh keypair, created and initialized in this instruction.
64    #[account(mut)]
65    pub mint: Signer<'info>,
66
67    #[account(
68        init,
69        payer = registrar,
70        space = 8 + Bond::INIT_SPACE,
71        seeds = [BOND_SEED, mint.key().as_ref()],
72        bump,
73    )]
74    pub bond: Box<Account<'info, Bond>>,
75
76    pub cash_mint: Box<InterfaceAccount<'info, Mint>>,
77
78    /// CHECK: the transfer hook's extra-account list, created and written here at the interface's seeds.
79    #[account(
80        init,
81        payer = registrar,
82        space = extra_account_metas_size(),
83        seeds = [EXTRA_ACCOUNT_METAS_SEED, mint.key().as_ref()],
84        bump,
85    )]
86    pub extra_account_meta_list: UncheckedAccount<'info>,
87
88    pub token_program: Program<'info, Token2022>,
89    pub system_program: Program<'info, System>,
90}
91
92pub fn handle_create_bond(ctx: Context<CreateBond>, terms: BondTerms) -> Result<()> {
93    validate_terms(&terms)?;
94    check_cash_mint(&ctx.accounts.cash_mint.to_account_info())?;
95    create_bond_mint(&ctx)?;
96    write_extra_account_metas(&ctx.accounts.extra_account_meta_list.to_account_info())?;
97
98    let bond = &mut ctx.accounts.bond;
99    bond.registrar = ctx.accounts.registrar.key();
100    bond.mint = ctx.accounts.mint.key();
101    bond.cash_mint = ctx.accounts.cash_mint.key();
102    bond.face_units = terms.face_units;
103    bond.rate_bps = terms.rate_bps;
104    bond.basis = terms.basis;
105    bond.redemption_price_bps = terms.redemption_price_bps;
106    bond.issue_day = terms.issue_day;
107    bond.maturity_day = terms.maturity_day;
108    bond.months_per_period = terms.months_per_period;
109    bond.payment_days = terms.payment_days;
110    bond.record_days = terms.record_days;
111    bond.record_seconds_local = terms.record_seconds_local;
112    bond.payment_seconds_local = terms.payment_seconds_local;
113    bond.utc_offset_seconds = terms.utc_offset_seconds;
114    bond.chain_origin = Clock::get()?.unix_timestamp;
115    bond.bond_origin = terms.bond_origin;
116    bond.chain_seconds_per_bond_day = terms.chain_seconds_per_bond_day;
117    bond.supply = PositionHistory::new(0);
118    bond.max_supply = terms.max_supply;
119    bond.bump = ctx.bumps.bond;
120
121    emit!(BondCreated {
122        bond: bond.key(),
123        mint: bond.mint,
124        registrar: bond.registrar,
125        periods: bond.periods(),
126    });
127    Ok(())
128}
129
130/// Create the mint account and initialize, in order: permanent delegate, transfer hook, permissioned burn
131/// (last, because the bundled interface crate cannot parse extensions after it), then the mint itself.
132fn create_bond_mint(ctx: &Context<CreateBond>) -> Result<()> {
133    let token_program = ctx.accounts.token_program.to_account_info();
134    let mint = ctx.accounts.mint.to_account_info();
135    let mint_key = mint.key();
136    let bond_key = ctx.accounts.bond.key();
137    let space = ExtensionType::try_calculate_account_len::<MintState>(&[
138        ExtensionType::PermanentDelegate,
139        ExtensionType::TransferHook,
140    ])
141    .map_err(|_| error!(BondError::InvalidTerms))?
142        + PERMISSIONED_BURN_MINT_SPACE;
143    system_program::create_account(
144        CpiContext::new(
145            ctx.accounts.system_program.key(),
146            CreateAccount { from: ctx.accounts.registrar.to_account_info(), to: mint.clone() },
147        ),
148        Rent::get()?.minimum_balance(space),
149        space as u64,
150        &spl_token_2022::ID,
151    )?;
152
153    let mut permissioned_burn = vec![PERMISSIONED_BURN_EXTENSION, PERMISSIONED_BURN_INITIALIZE];
154    permissioned_burn.extend_from_slice(bond_key.as_ref());
155    let instructions = [
156        spl_token_2022::instruction::initialize_permanent_delegate(&spl_token_2022::ID, &mint_key, &bond_key)?,
157        transfer_hook::instruction::initialize(&spl_token_2022::ID, &mint_key, Some(bond_key), Some(crate::ID))?,
158        Instruction {
159            program_id: spl_token_2022::ID,
160            accounts: vec![AccountMeta::new(mint_key, false)],
161            data: permissioned_burn,
162        },
163        spl_token_2022::instruction::initialize_mint2(&spl_token_2022::ID, &mint_key, &bond_key, None, 0)?,
164    ];
165    for ix in instructions.iter() {
166        invoke(ix, &[mint.clone(), token_program.clone()])?;
167    }
168    Ok(())
169}
170
171/// The cash token must deliver exactly the amount sent, with no extra accounts, and only the program may move a
172/// vault's cash: classic SPL Token, or Token-2022 without a transfer fee, a transfer hook, non-transferability or a
173/// permanent delegate (which could move any vault's cash without the program). Unreadable extensions are refused.
174/// The token's freeze authority and a pause switch stay with its issuer: they can stop payments, not take the cash.
175fn check_cash_mint(info: &AccountInfo) -> Result<()> {
176    if *info.owner == anchor_spl::token::ID {
177        return Ok(());
178    }
179    require_keys_eq!(*info.owner, spl_token_2022::ID, BondError::UnsupportedCashToken);
180    let data = info.try_borrow_data()?;
181    let mint = StateWithExtensions::<MintState>::unpack(&data).map_err(|_| error!(BondError::UnsupportedCashToken))?;
182    let types = mint.get_extension_types().map_err(|_| error!(BondError::UnsupportedCashToken))?;
183    let refused = [
184        ExtensionType::TransferFeeConfig,
185        ExtensionType::TransferHook,
186        ExtensionType::NonTransferable,
187        ExtensionType::PermanentDelegate,
188    ];
189    require!(!types.iter().any(|t| refused.contains(t)), BondError::UnsupportedCashToken);
190    Ok(())
191}
192
193fn validate_terms(t: &BondTerms) -> Result<()> {
194    let day = 0..SECONDS_PER_DAY as i32;
195    require!(
196        t.face_units > 0
197            && t.max_supply > 0
198            && t.rate_bps > 0
199            && t.redemption_price_bps > 0
200            && t.months_per_period > 0
201            && t.chain_seconds_per_bond_day > 0
202            && day.contains(&t.record_seconds_local)
203            && day.contains(&t.payment_seconds_local)
204            && t.weekend_days & 0x7f != 0x7f,
205        BondError::InvalidTerms
206    );
207    match t.basis {
208        CouponBasis::FixedPerPeriod { periods_per_year } => require!(
209            periods_per_year > 0 && periods_per_year * t.months_per_period == 12,
210            BondError::InvalidTerms
211        ),
212        CouponBasis::Thirty360 { convention } => require!(convention <= 2, BondError::InvalidTerms),
213    }
214
215    // Recompute the unadjusted schedule from the terms and check the registrar's days against it.
216    let issue = Date::from_days(i64::from(t.issue_day));
217    let maturity = Date::from_days(i64::from(t.maturity_day));
218    let no_holidays = Calendar::new(&[], &[]).map_err(|_| error!(BondError::InvalidTerms))?;
219    let periods = regular_schedule(issue, maturity, t.months_per_period, &no_holidays, BusinessDayConvention::Unadjusted)
220        .map_err(|_| error!(BondError::InvalidSchedule))?;
221    require!(
222        periods.len() == t.payment_days.len() && periods.len() == t.record_days.len() && periods.len() <= MAX_PERIODS,
223        BondError::InvalidSchedule
224    );
225    let business_day = |day: i32| t.weekend_days & (1 << Date::from_days(i64::from(day)).weekday() as u8) == 0;
226    let mut previous_record = i64::MIN;
227    for ((p, &pay), &rec) in periods.iter().zip(&t.payment_days).zip(&t.record_days) {
228        let shift = i64::from(pay) - p.accrual_end.days();
229        let record_moment = i64::from(rec) * SECONDS_PER_DAY + i64::from(t.record_seconds_local);
230        let payment_moment = i64::from(pay) * SECONDS_PER_DAY + i64::from(t.payment_seconds_local);
231        require!(
232            (-MAX_PAYMENT_DELAY_DAYS..=MAX_PAYMENT_DELAY_DAYS).contains(&shift)
233                && business_day(pay)
234                && business_day(rec)
235                && record_moment > previous_record
236                && record_moment <= payment_moment,
237            BondError::InvalidSchedule
238        );
239        previous_record = record_moment;
240    }
241    Ok(())
242}