1use anchor_lang::prelude::*;
2use bond_math::amount::{coupon_amount, fixed_coupon_amount, principal_amount, AccrualBasis};
3use bond_math::date::Date;
4use bond_math::daycount::Thirty360;
5use bond_math::time::{chain_to_bond_time, SECONDS_PER_DAY};
6
7use crate::constants::MAX_PERIODS;
8use crate::error::BondError;
9
10/// How a coupon is computed (see `bond_math::amount::AccrualBasis`).
11#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Eq, InitSpace)]
12pub enum CouponBasis {
13 /// Rate / periods per year for every regular period.
14 FixedPerPeriod { periods_per_year: u32 },
15 /// 30/360 accrual: 0 = BondBasis, 1 = European, 2 = USA.
16 Thirty360 { convention: u8 },
17}
18
19#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Eq, InitSpace)]
20pub enum EventKind {
21 Coupon,
22 Redemption,
23}
24
25/// A quantity over the bond's record moments. Record moments are numbered 0..periods in time order.
26///
27/// `quantity` is the quantity now. It has not changed since the last change, which happened when `live_from`
28/// record moments had passed. So the quantity at record moment `e` is:
29/// - `at[e]` if `e < live_from` (saved by the first change after moment `e`);
30/// - `quantity` otherwise (no change since moment `e`).
31/// `save` must run before every change; then no record moment's quantity is ever lost or overwritten, however
32/// late anyone looks.
33#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Eq, InitSpace)]
34pub struct PositionHistory {
35 pub quantity: u64,
36 pub live_from: u16,
37 pub at: [u64; MAX_PERIODS],
38}
39
40impl PositionHistory {
41 /// A new position created when `passed` record moments have passed: zero at all of them.
42 pub fn new(passed: u16) -> Self {
43 Self { quantity: 0, live_from: passed, at: [0; MAX_PERIODS] }
44 }
45
46 /// Keep the current quantity for every record moment passed since the last change. Returns the saved
47 /// range of record moments, if any.
48 pub fn save(&mut self, passed: u16) -> Option<(u16, u16)> {
49 if passed <= self.live_from {
50 return None;
51 }
52 let from = self.live_from;
53 for e in from..passed {
54 self.at[e as usize] = self.quantity;
55 }
56 self.live_from = passed;
57 Some((from, passed))
58 }
59
60 /// Quantity at record moment `e`; valid once moment `e` has passed.
61 pub fn at_moment(&self, e: u16) -> u64 {
62 if e < self.live_from {
63 self.at[e as usize]
64 } else {
65 self.quantity
66 }
67 }
68}
69
70#[account]
71#[derive(InitSpace)]
72pub struct Bond {
73 pub registrar: Pubkey,
74 pub mint: Pubkey,
75 pub cash_mint: Pubkey,
76 /// Face value of one bond in cash-token base units.
77 pub face_units: u64,
78 pub rate_bps: u32,
79 pub basis: CouponBasis,
80 /// Redemption price in basis points of face (10_000 = par).
81 pub redemption_price_bps: u32,
82 /// Day serials (days since 1970-01-01).
83 pub issue_day: i32,
84 pub maturity_day: i32,
85 pub months_per_period: u32,
86 /// Business-day-adjusted payment day of each coupon period (computed off-chain with the bond's calendar).
87 #[max_len(MAX_PERIODS)]
88 pub payment_days: Vec<i32>,
89 /// Record day of each coupon period; the record moment is `record_seconds_local` on that day.
90 #[max_len(MAX_PERIODS)]
91 pub record_days: Vec<i32>,
92 pub record_seconds_local: i32,
93 /// Payment opens at this local time on the payment day (never before the record moment).
94 pub payment_seconds_local: i32,
95 pub utc_offset_seconds: i32,
96 /// Time model: bond time = bond_origin + (chain time - chain_origin) * 86_400 / chain_seconds_per_bond_day.
97 pub chain_origin: i64,
98 pub bond_origin: i64,
99 pub chain_seconds_per_bond_day: i64,
100 /// Bond supply over record moments. Only the program changes supply (placement, redemption), and it saves
101 /// the history first, so the supply at any record moment is known without counting holders.
102 pub supply: PositionHistory,
103 /// Issue size in bonds, fixed by the terms: placement never takes the supply above it.
104 pub max_supply: u64,
105 pub bump: u8,
106}
107
108impl Bond {
109 pub fn periods(&self) -> u16 {
110 self.payment_days.len() as u16
111 }
112
113 /// Index of the redemption event; coupon events are 0..periods.
114 pub fn redemption_event(&self) -> u16 {
115 self.periods()
116 }
117
118 /// The coupon period an event belongs to: the redemption shares the final period's dates.
119 pub fn period_of(&self, event: u16) -> Result<u16> {
120 require!(event <= self.redemption_event(), BondError::EventOutOfRange);
121 Ok(if event == self.redemption_event() { event - 1 } else { event })
122 }
123
124 pub fn now(&self) -> Result<i64> {
125 let chain_now = Clock::get()?.unix_timestamp;
126 chain_to_bond_time(chain_now, self.chain_origin, self.bond_origin, self.chain_seconds_per_bond_day)
127 .ok_or_else(|| error!(BondError::Overflow))
128 }
129
130 fn local_moment(&self, day: i32, seconds_local: i32) -> i64 {
131 i64::from(day) * SECONDS_PER_DAY + i64::from(seconds_local) - i64::from(self.utc_offset_seconds)
132 }
133
134 /// Record moment (bond time) of an event.
135 pub fn record_time(&self, event: u16) -> Result<i64> {
136 let period = self.period_of(event)?;
137 Ok(self.local_moment(self.record_days[period as usize], self.record_seconds_local))
138 }
139
140 /// Moment (bond time) from which an event may be paid.
141 pub fn payment_time(&self, event: u16) -> Result<i64> {
142 let period = self.period_of(event)?;
143 Ok(self.local_moment(self.payment_days[period as usize], self.payment_seconds_local))
144 }
145
146 /// Trading closes at the final record moment: balances are frozen for redemption.
147 pub fn final_record_time(&self) -> Result<i64> {
148 self.record_time(self.periods() - 1)
149 }
150
151 /// Number of record moments at or before `bond_now`.
152 pub fn passed_record_moments(&self, bond_now: i64) -> Result<u16> {
153 self.passed_record_moments_from(bond_now, 0)
154 }
155
156 /// Holder-local cursors provide a period hint; no shared mutable epoch cache.
157 pub fn passed_record_moments_from(&self, bond_now: i64, hint: u16) -> Result<u16> {
158 let mut lo = usize::from(hint).min(self.record_days.len());
159 // Preserve exact lookup even if a supplied hint is ahead of the queried time.
160 if lo > 0 && self.local_moment(self.record_days[lo - 1], self.record_seconds_local) > bond_now { lo = 0; }
161 if lo == self.record_days.len() || self.local_moment(self.record_days[lo], self.record_seconds_local) > bond_now {
162 return Ok(lo as u16);
163 }
164 let passed = self.record_days[lo..].partition_point(|day| self.local_moment(*day, self.record_seconds_local) <= bond_now);
165 Ok((lo + passed) as u16)
166 }
167
168 /// Unadjusted accrual dates of coupon period `i` (regular schedule, counted back from maturity).
169 pub fn accrual_dates(&self, i: u16) -> (Date, Date) {
170 let maturity = Date::from_days(i64::from(self.maturity_day));
171 let back = i32::from(self.periods() - i);
172 let step = self.months_per_period as i32;
173 (maturity.add_months(-back * step), maturity.add_months(-(back - 1) * step))
174 }
175
176 pub fn accrual_basis(&self) -> Result<AccrualBasis> {
177 Ok(match self.basis {
178 CouponBasis::FixedPerPeriod { periods_per_year } => AccrualBasis::FixedPerPeriod { periods_per_year },
179 CouponBasis::Thirty360 { convention } => AccrualBasis::DayCount(match convention {
180 0 => Thirty360::BondBasis,
181 1 => Thirty360::European,
182 2 => Thirty360::Usa,
183 _ => return err!(BondError::InvalidTerms),
184 }),
185 })
186 }
187
188 /// Coupon on a holding of `position` bonds for coupon period `period`, rounded down once.
189 pub fn coupon(&self, position: u64, period: u16) -> Result<u64> {
190 let amount = match self.basis {
191 CouponBasis::FixedPerPeriod { periods_per_year } => fixed_coupon_amount(position, self.face_units, self.rate_bps, periods_per_year),
192 _ => { let (start, end) = self.accrual_dates(period);
193 coupon_amount(position, self.face_units, self.rate_bps, self.accrual_basis()?, start, end) }
194 };
195 amount.ok_or_else(|| error!(BondError::Overflow))
196 }
197
198 pub fn principal(&self, position: u64) -> Result<u64> {
199 principal_amount(position, self.face_units, self.redemption_price_bps).ok_or_else(|| error!(BondError::Overflow))
200 }
201
202 /// Every amount the bond can owe on `supply` bonds (each coupon budget and the redemption budget) fits the cash
203 /// token's u64 amounts. Checked when supply grows (placement); supply only shrinks afterwards.
204 pub fn check_serviceable(&self, supply: u64) -> Result<()> {
205 let basis = self.accrual_basis()?;
206 match basis {
207 AccrualBasis::FixedPerPeriod { periods_per_year } => {
208 fixed_coupon_amount(supply, self.face_units, self.rate_bps, periods_per_year).ok_or(BondError::IssueTooLarge)?;
209 }
210 _ => { for period in 0..self.periods() {
211 let (start, end) = self.accrual_dates(period);
212 coupon_amount(supply, self.face_units, self.rate_bps, basis, start, end).ok_or(BondError::IssueTooLarge)?;
213 } }
214 }
215 principal_amount(supply, self.face_units, self.redemption_price_bps).ok_or(BondError::IssueTooLarge)?;
216 Ok(())
217 }
218}
219
220/// One registered holder of one bond: its quantity over record moments and the coupons already paid.
221#[account]
222#[derive(InitSpace)]
223pub struct HolderRecord {
224 pub bond: Pubkey,
225 pub owner: Pubkey,
226 pub position: PositionHistory,
227 /// Bit `e` set = coupon `e` paid.
228 pub paid: u64,
229 /// Bonds committed to offers: tendered into open offers, or accepted by a closed offer and not yet bought. They
230 /// stay in the holder's account (and keep their coupon rights), but the transfer hook refuses transfers that
231 /// would take the balance below this, and redemption leaves them to their offer.
232 pub locked: u64,
233 /// Bonds redeemed at maturity so far. Principal for each partial redemption is the difference of the principal
234 /// on the cumulative quantity, so several partial redemptions round exactly like one.
235 pub redeemed: u64,
236 pub bump: u8,
237}
238
239impl HolderRecord {
240 pub fn is_paid(&self, event: u16) -> bool {
241 self.paid & (1u64 << event) != 0
242 }
243
244 pub fn unlocked(&self) -> u64 {
245 self.position.quantity.saturating_sub(self.locked)
246 }
247}
248
249#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug, PartialEq, Eq, InitSpace)]
250pub enum OfferKind {
251 /// Buyback: holders sell at once, first come first served, until the maximum is bought.
252 FirstCome,
253 /// Tender offer: holders tender during the window; after it, pro rata if oversubscribed.
254 ProRata,
255}
256
257/// An issuer's offer to buy back bonds at a price per bond, up to a maximum quantity, during a window.
258#[account]
259#[derive(InitSpace)]
260pub struct Offer {
261 pub bond: Pubkey,
262 pub id: u16,
263 pub kind: OfferKind,
264 /// All-in cash price per bond, in cash-token base units.
265 pub price_units: u64,
266 pub max_quantity: u64,
267 /// Window in bond time: open <= t < close.
268 pub open_time: i64,
269 pub close_time: i64,
270 /// Bonds bought (FirstCome) or currently tendered (ProRata).
271 pub total: u64,
272 /// The offer's own cash account.
273 pub vault: Pubkey,
274 pub funded: bool,
275 /// Tenders made (ProRata): each new tender takes the next number.
276 pub tenders: u32,
277 /// Tenders resolved so far, in the order they were made.
278 pub resolved: u32,
279 /// Bonds tendered by the tenders resolved so far.
280 pub resolved_quantity: u64,
281 pub bump: u8,
282}
283
284impl Offer {
285 /// Cash that covers the whole offer.
286 pub fn budget(&self) -> Option<u64> {
287 self.price_units.checked_mul(self.max_quantity)
288 }
289
290 /// Bonds accepted from the next tender of `quantity`, once the window has closed. Not oversubscribed: all of it.
291 /// Oversubscribed: tenders are taken in the order they were made, and each receives the pro-rata share of all
292 /// tenders up to and including it, rounded down, minus that of the tenders before it. The shares telescope, so the
293 /// offer buys exactly its maximum; each tender receives at least its own pro-rata share rounded down, never more
294 /// than it tendered, and differs from its exact share by less than one bond.
295 pub fn accept_next(&mut self, quantity: u64) -> Option<u64> {
296 let before = self.resolved_quantity;
297 let after = before.checked_add(quantity)?;
298 let accepted = if self.total <= self.max_quantity {
299 quantity
300 } else {
301 let share = |q: u64| (u128::from(q) * u128::from(self.max_quantity) / u128::from(self.total)) as u64;
302 share(after) - share(before)
303 };
304 self.resolved_quantity = after;
305 self.resolved = self.resolved.checked_add(1)?;
306 Some(accepted)
307 }
308}
309
310/// One holder's tender into one pro-rata offer.
311#[account]
312#[derive(InitSpace)]
313pub struct Tender {
314 pub offer: Pubkey,
315 pub owner: Pubkey,
316 /// Its number in the offer: tenders are resolved in this order.
317 pub number: u32,
318 pub quantity: u64,
319 /// After the window: the accepted part is fixed and the rejected part released (no cash involved).
320 pub resolved: bool,
321 pub accepted: u64,
322 /// The accepted part has been bought (burned and paid).
323 pub settled: bool,
324 pub bump: u8,
325}
326
327/// One coupon or the redemption. Created once its record moment has passed, so its inputs are final.
328/// Read-only on the payment path.
329#[account]
330#[derive(InitSpace)]
331pub struct Event {
332 pub bond: Pubkey,
333 pub index: u16,
334 pub kind: EventKind,
335 /// Record moment and payment opening, in bond time.
336 pub record_time: i64,
337 pub payment_time: i64,
338 /// Bond supply at the record moment (from the bond's supply history).
339 pub supply_at_record: u64,
340 /// Cash that covers every holder of this event: the amount for the whole supply, rounded down once. Each
341 /// holder's amount is rounded down on its own, so the holders' amounts never add up to more than this.
342 pub budget: u64,
343 /// This event's own cash account; other events cannot spend it.
344 pub vault: Pubkey,
345 pub funded: bool,
346 pub bump: u8,
347}