Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions finance/fundraiser/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 2026-10-07

### Changed

- **Two refusal tests now assert their error code instead of any failure.** `test_stale_contribution_cannot_refund_from_next_raise` asserts that the stale refund fails with Anchor's `AccountNotInitialized` (3012): the first-raise Contribution account was closed, so its address is empty and Anchor refuses it before the handler runs. `test_reinitialize_with_open_contributions_fails` asserts that `initialize_fundraiser` fails with the System Program's `AccountAlreadyInUse` (custom error 0): the claimed fundraiser still occupies the PDA, so `init` cannot allocate it. No program changes.

## 2026-10-03

### Changed
Expand Down
2 changes: 1 addition & 1 deletion finance/fundraiser/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exerci

It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contribution account after a claim, with the tokens and rent going to the contributor.

`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributions_fails` and `test_close_fundraiser_with_open_contributions_fails` check that the second raise cannot start while any first-raise Contribution account is open.
`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails with Anchor's `AccountNotInitialized`, because their Contribution account no longer exists, while every second-raise contributor gets back exactly what they put in. `test_close_fundraiser_with_open_contributions_fails` checks that the claimed fundraiser cannot close while any Contribution account is open (`ContributionsOpen`), and `test_reinitialize_with_open_contributions_fails` checks that a second raise cannot start while the claimed fundraiser still exists: `init` fails with the System Program's `AccountAlreadyInUse`.

`close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contribution account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,27 @@ fn assert_error<T: std::fmt::Debug>(result: Result<T, String>, expected_error: F
);
}

/// Anchor's `ErrorCode::AccountNotInitialized`: an account the instruction
/// declares as `Account<T>` has no data and is still owned by the System
/// Program.
const ANCHOR_ACCOUNT_NOT_INITIALIZED: u32 = 3012;

/// The System Program's `SystemError::AccountAlreadyInUse`: `init` asked it to
/// allocate an address that already holds an account.
const SYSTEM_ACCOUNT_ALREADY_IN_USE: u32 = 0;

/// Asserts that a transaction failed with the given error code, for errors
/// raised by Anchor itself or by a program it calls rather than by
/// `FundraiserError`.
fn assert_error_code<T: std::fmt::Debug>(result: Result<T, String>, expected_code: u32) {
let error = result.expect_err("transaction should have failed");
let expected_code = format!("Custom({expected_code})");
assert!(
error.contains(&expected_code),
"expected {expected_code}, got: {error}"
);
}

#[test]
fn test_initialize_fundraiser() {
let mut setup = full_setup();
Expand Down Expand Up @@ -1008,11 +1029,11 @@ fn test_reinitialize_with_open_contributions_fails() {
vec![initialize_instruction],
&[&setup.maker],
&setup.maker.pubkey(),
);
assert!(
result.is_err(),
"A new fundraiser must not start while the claimed one exists"
);
)
.map_err(|error| format!("{error:?}"));
// `init` asks the System Program to allocate the fundraiser's address,
// which still holds the claimed fundraiser, so it refuses.
assert_error_code(result, SYSTEM_ACCOUNT_ALREADY_IN_USE);
let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda);
assert!(fundraiser_state.claimed);
}
Expand Down Expand Up @@ -1048,10 +1069,13 @@ fn test_stale_contribution_cannot_refund_from_next_raise() {

// A raise-one contributor tries to take a refund from raise two. Their
// contribution account was closed with raise one, so there is nothing to
// refund.
// refund: Anchor refuses the empty address before the handler runs.
let stale_contributor = &first_raise_contributors[0];
let fee_payer = stale_contributor.keypair.insecure_clone();
assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err());
assert_error_code(
refund(&mut setup, &fee_payer, stale_contributor),
ANCHOR_ACCOUNT_NOT_INITIALIZED,
);
assert_eq!(
get_token_account_balance(&setup.svm, &setup.vault).unwrap(),
2 * CONTRIBUTION
Expand Down
6 changes: 6 additions & 0 deletions finance/fundraiser/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 2026-10-07

### Changed

- **Two refusal tests now assert their error code instead of any failure.** `test_stale_contribution_cannot_refund_from_next_raise` asserts that the stale refund fails with the runtime's `UninitializedAccount` (`InstructionError::UninitializedAccount`, which Anchor 2 returns for an empty account address): the first-raise Contribution account was closed, so its address is empty and Anchor refuses it before the handler runs. `test_reinitialize_with_open_contributions_fails` asserts that `initialize_fundraiser` fails with the System Program's `AccountAlreadyInUse` (custom error 0): the claimed fundraiser still occupies the PDA, so `init` cannot allocate it. No program changes.

## 2026-10-03

### Changed
Expand Down
2 changes: 1 addition & 1 deletion finance/fundraiser/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exerci

It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contribution account after a claim, with the tokens and rent going to the contributor.

`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributions_fails` and `test_close_fundraiser_with_open_contributions_fails` check that the second raise cannot start while any first-raise Contribution account is open.
`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails with the runtime's `UninitializedAccount`, because their Contribution account no longer exists, while every second-raise contributor gets back exactly what they put in. `test_close_fundraiser_with_open_contributions_fails` checks that the claimed fundraiser cannot close while any Contribution account is open (`ContributionsOpen`), and `test_reinitialize_with_open_contributions_fails` checks that a second raise cannot start while the claimed fundraiser still exists: `init` fails with the System Program's `AccountAlreadyInUse`.

`close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contribution account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,34 @@ fn assert_error<T: std::fmt::Debug>(result: Result<T, String>, expected_error: F
);
}

/// The System Program's `SystemError::AccountAlreadyInUse`: `init` asked it to
/// allocate an address that already holds an account.
const SYSTEM_ACCOUNT_ALREADY_IN_USE: u32 = 0;

/// Asserts that a transaction failed with the given custom error code, for
/// errors raised by a program the fundraiser calls rather than by
/// `FundraiserError`.
fn assert_error_code<T: std::fmt::Debug>(result: Result<T, String>, expected_code: u32) {
let error = result.expect_err("transaction should have failed");
let expected_code = format!("Custom({expected_code})");
assert!(
error.contains(&expected_code),
"expected {expected_code}, got: {error}"
);
}

/// Asserts that a transaction's only instruction failed with the given
/// built-in runtime error, such as `UninitializedAccount`, which Anchor 2
/// returns as a `ProgramError` rather than as a custom code.
fn assert_instruction_error<T: std::fmt::Debug>(result: Result<T, String>, expected_error: &str) {
let error = result.expect_err("transaction should have failed");
let expected = format!("InstructionError(0, {expected_error})");
assert!(
error.contains(&expected),
"expected {expected}, got: {error}"
);
}

#[test]
fn test_initialize_fundraiser() {
let mut setup = full_setup();
Expand Down Expand Up @@ -1008,11 +1036,11 @@ fn test_reinitialize_with_open_contributions_fails() {
vec![initialize_instruction],
&[&setup.maker],
&setup.maker.pubkey(),
);
assert!(
result.is_err(),
"A new fundraiser must not start while the claimed one exists"
);
)
.map_err(|error| format!("{error:?}"));
// `init` asks the System Program to allocate the fundraiser's address,
// which still holds the claimed fundraiser, so it refuses.
assert_error_code(result, SYSTEM_ACCOUNT_ALREADY_IN_USE);
let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda);
assert!(fundraiser_state.claimed);
}
Expand Down Expand Up @@ -1048,10 +1076,14 @@ fn test_stale_contribution_cannot_refund_from_next_raise() {

// A raise-one contributor tries to take a refund from raise two. Their
// contribution account was closed with raise one, so there is nothing to
// refund.
// refund: Anchor refuses the empty address before the handler runs, with
// the runtime's `UninitializedAccount`.
let stale_contributor = &first_raise_contributors[0];
let fee_payer = stale_contributor.keypair.insecure_clone();
assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err());
assert_instruction_error(
refund(&mut setup, &fee_payer, stale_contributor),
"UninitializedAccount",
);
assert_eq!(
get_token_account_balance(&setup.svm, &setup.vault).unwrap(),
2 * CONTRIBUTION
Expand Down
23 changes: 23 additions & 0 deletions finance/lending/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## Unreleased (2026-10-07)

Round interest against the borrower. Every debt is `borrowed_principal`
times `borrow_accumulation_factor`, and the debt itself was already ceiled,
but the arithmetic that grows the factor floored at every step, each time in
the borrower's favor. Five divisions now round up with `mul_div_ceil`:
`Reserve::utilization_bps` (its only use is the borrow rate), both segments of
the kinked-curve interpolation in `current_borrow_rate_per_second`, the
conversion of that APR to a per-second rate, and the factor update in
`accrue_interest`. The rate still stays within `[min, max]` and utilization
within 10,000 bps. Suppliers are not overpaid by it: the reserve counts its
debt as `borrowed_principal` times the same factor, ceiled once, which is never
more than the sum of the borrowers' individually ceiled debts, so the pool's
assets never include interest no borrower owes; redemptions still floor and
the program fee still rounds up. On the book's walkthrough (750 borrowed from
a 2,000 USDC pool for five weeks) Bob's debt rises from 757.501028 to
757.508220 USDC. Tested by
`accumulation_factor_rounds_up_against_the_borrower`, which runs a second
accrual from a factor no longer at 1.0, checks that flooring would have given
a smaller utilization, APR, rate and factor, and asserts the program's factor
is the ceiled one. The test helper `factor_after` in `test_reserve.rs` now
rounds up too.

## Unreleased (2026-10-05)

Cap the borrow rate, ratchet the liquidation threshold and keep the bonus
Expand Down
12 changes: 11 additions & 1 deletion finance/lending/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ utilization. Each borrow stores its principal as **scaled debt** (principal 梅
index at borrow time), so every obligation's debt grows automatically as the
index advances: no per-obligation accrual loop.

Every division on the way to the factor rounds up, against the borrower: the
utilization, the climb along the curve, the per-second rate and the factor
update itself. A debt is principal times the factor, so flooring any of them
would understate every debt. Suppliers are not overpaid by it: the reserve
counts its own debt as its total principal times the same factor, ceiled once,
which is never more than the borrowers' individually ceiled debts add up to
(`accumulation_factor_rounds_up_against_the_borrower` checks a second accrual
against both roundings).

Those curve parameters are annual, and the conversion to a per-second rate
divides by `SECONDS_PER_YEAR`. Elapsed time is the Clock's `unix_timestamp`
minus the reserve's `last_accrual_timestamp`, so a borrower pays the advertised
Expand Down Expand Up @@ -178,7 +187,8 @@ less, which would make the liquidator overpay.
All arithmetic is integer-only `u128`: no floats, no fixed-point crates. Ratios
(rates, the index, the exchange rate, obligation values) are scaled by
`FIXED_POINT_SCALE` (10^18). Every conversion rounds in the program's favour
(user output floored, debt and the program fee ceiled), so dust cannot be
(user output floored; debt, the interest that grows it, and the program fee
ceiled), so dust cannot be
extracted by repeated
round-trips; `deposit_redeem_round_trip_creates_no_value` checks this by
depositing and redeeming 777,777,777 units fifty times against a reserve whose
Expand Down
26 changes: 18 additions & 8 deletions finance/lending/anchor-v1/programs/lending/src/state/reserve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::constants::{
SECONDS_PER_YEAR,
};
use crate::errors::LendingError;
use crate::math::{mul_div_ceil, mul_div_floor};
use crate::math::mul_div_ceil;

/// Signer seeds for a reserve PDA, which is the authority over its liquidity
/// vault and the mint authority of its share token.
Expand Down Expand Up @@ -213,13 +213,16 @@ impl Reserve {
.ok_or(LendingError::MathOverflow.into())
}

/// Borrowed fraction of the pool, in basis points (0..=10_000).
/// Borrowed fraction of the pool, in basis points (0..=10_000). Rounded
/// up, because its only use is the borrow rate, and a floored utilization
/// would charge the borrower a lower rate. It still never exceeds 10_000,
/// since the debt is part of the gross liquidity it is divided by.
pub fn utilization_bps(&self) -> Result<u128> {
let gross = self.gross_liquidity()?;
if gross == 0 {
return Ok(0);
}
mul_div_floor(
mul_div_ceil(
self.current_borrowed_amount()? as u128,
BPS_DENOMINATOR,
gross,
Expand All @@ -229,6 +232,11 @@ impl Reserve {
/// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve:
/// linear from `min` to `optimal` up to the kink, then steeper from `optimal`
/// to `max` between the kink and full utilization.
///
/// Both divisions round up: the interpolated APR and the per-second rate
/// derived from it. A rate is what the borrower is charged, so like the
/// debt it rounds against the borrower. The interpolation still never
/// leaves `[min, max]`, because the climbed amount is at most the range.
pub fn current_borrow_rate_per_second(&self) -> Result<u128> {
let utilization = self.utilization_bps()?;
let optimal_utilization = self.config.optimal_utilization_bps as u128;
Expand All @@ -237,7 +245,7 @@ impl Reserve {
let rate_range = (self.config.optimal_borrow_rate_bps as u128)
.checked_sub(self.config.min_borrow_rate_bps as u128)
.ok_or(LendingError::MathOverflow)?;
let climbed = mul_div_floor(rate_range, utilization, optimal_utilization)?;
let climbed = mul_div_ceil(rate_range, utilization, optimal_utilization)?;
(self.config.min_borrow_rate_bps as u128)
.checked_add(climbed)
.ok_or(LendingError::MathOverflow)?
Expand All @@ -251,7 +259,7 @@ impl Reserve {
let utilization_range = BPS_DENOMINATOR
.checked_sub(optimal_utilization)
.ok_or(LendingError::MathOverflow)?;
let climbed = mul_div_floor(rate_range, utilization_above, utilization_range)?;
let climbed = mul_div_ceil(rate_range, utilization_above, utilization_range)?;
(self.config.optimal_borrow_rate_bps as u128)
.checked_add(climbed)
.ok_or(LendingError::MathOverflow)?
Expand All @@ -261,14 +269,16 @@ impl Reserve {
let per_year_denominator = BPS_DENOMINATOR
.checked_mul(SECONDS_PER_YEAR)
.ok_or(LendingError::MathOverflow)?;
mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator)
mul_div_ceil(apr_bps, FIXED_POINT_SCALE, per_year_denominator)
}

/// Advance the accumulation factor for the seconds elapsed since the last
/// accrual, and record `current_slot` as the slot of this refresh.
/// `new_factor = old_factor * (1 + rate_per_second * elapsed_seconds)`, a
/// single multiply per refresh that compounds across refreshes (Solend's
/// approach, on the wall clock rather than the slot count).
/// approach, on the wall clock rather than the slot count). The product
/// rounds up: every debt is principal times this factor, so a floored
/// factor would understate every borrower's debt.
///
/// The timestamp is written by each block's leader. The runtime rejects a
/// block whose time goes backwards, but a timestamp at or before the stored
Expand All @@ -292,7 +302,7 @@ impl Reserve {
let growth_factor = FIXED_POINT_SCALE
.checked_add(accrued)
.ok_or(LendingError::MathOverflow)?;
self.borrow_accumulation_factor = mul_div_floor(
self.borrow_accumulation_factor = mul_div_ceil(
self.borrow_accumulation_factor,
growth_factor,
FIXED_POINT_SCALE,
Expand Down
Loading
Loading