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}